feat: Import EPUB (#833)

* implement classes for plugins

* feat: plugin manages UA, cookie itself

* fix: repo path

* safer regexString

* feat: EpubParser native module

* add reject to parser

* local category, better insert category trigger

* add isLocal field to Novel

* category update logic for local novels

* feat: import epub

* delete downloaded folder when delete novel

* local novel screen

* update build.yml

* fix: novel without cover
This commit is contained in:
nyagami
2023-11-10 20:01:09 +07:00
committed by GitHub
parent 5c818be5ab
commit 7fbdd92117
26 changed files with 715 additions and 205 deletions
+5 -2
View File
@@ -8,13 +8,16 @@ jobs:
name: Build App
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Clone Repository (Latest)
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install Dependencies
run: |
npm install --legacy-peer-deps
npm install
- name: Cache Gradle Wrapper
uses: actions/cache@v3
@@ -0,0 +1,162 @@
package com.rajarsheechatterjee.EpubParser;
import androidx.annotation.NonNull;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.WritableNativeArray;
import com.facebook.react.bridge.WritableNativeMap;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
public class EpubParser extends ReactContextBaseJavaModule {
String rootPath;
String contentDirPath;
XPath xPath = XPathFactory.newInstance().newXPath();
DocumentBuilder db = DocumentBuilderFactory.newInstance().newDocumentBuilder();
EpubParser(ReactApplicationContext context) throws Exception {
super(context);
}
@NonNull
@Override
public String getName() {
return "EpubParser";
}
@ReactMethod
public void parse(String epubFilePath, String epubDirPath, Promise promise) {
try{
this.rootPath = epubDirPath;
String novelCover = this.unzip(epubFilePath, epubDirPath);
File contentFile = this.parseContainer(epubDirPath);
this.contentDirPath = contentFile.getParent();
WritableMap novel = (WritableMap) this.parseContent(contentFile);
if(novelCover != null){
novel.putString("cover", new File(epubDirPath, novelCover).getAbsolutePath());
}
promise.resolve(novel);
} catch (Exception e) {
promise.reject(e.getCause());
}
}
public File parseContainer(String epubDir) throws Exception {
Document doc = this.parserDocument(new File(epubDir, "META-INF/container.xml"));
return new File(this.rootPath,
((Element) doc.getElementsByTagName("rootfile").item(0)).getAttribute("full-path"));
}
public ReadableMap parseContent(File contentFile) throws Exception {
Document content = this.parserDocument(contentFile);
WritableMap novel = new WritableNativeMap();
novel.putString("name", this.getFirstElementByTag(content, "dc:title").getTextContent().trim());
novel.putString("url", contentFile.getParent());
novel.putString("author", this.getFirstElementByTag(content, "dc:creator").getTextContent().trim());
novel.putArray("chapters", this.getChapters(content));
return novel;
}
public ReadableArray getChapters(Document doc) throws Exception {
WritableArray chapters = new WritableNativeArray();
Document toc = null;
if (this.getFirstElementByTag(doc, "spine").getAttribute("toc") != null) {
toc = this.parserDocument(new File(this.contentDirPath, "toc.ncx"));
}
NodeList itemrefs = this.getNodeListByTag(doc, "itemref");
for (int i = 0; i < itemrefs.getLength(); i++) {
WritableMap chapter = new WritableNativeMap();
Element itemref = (Element) itemrefs.item(i);
chapter.putString("name", itemref.getAttribute("idref"));
Element item = this.getElementById(doc, chapter.getString("name"));
chapter.putString("url", new File(this.contentDirPath, item.getAttribute("href")).getAbsolutePath());
if(toc != null){
chapter.putString("name", this.getChapterName(toc, item.getAttribute(("href"))));
if(chapter.getString("name") == null) continue;
}
chapters.pushMap(chapter);
}
return chapters;
}
public String getChapterName(Document doc, String href) throws Exception {
String path = String.format("//*[starts-with(@src,'%1$s')]", href);
Node node = ((NodeList) this.xPath.compile(path).evaluate(doc, XPathConstants.NODESET)).item(0);
if(node != null){
String text = node.getPreviousSibling().getTextContent().trim();
if(text.isEmpty()) text = node.getPreviousSibling().getPreviousSibling().getTextContent().trim();
return !text.isEmpty() ? text : null;
}
return null;
}
public Element getElementById(Document doc, String id) throws Exception {
return (Element) ((NodeList) this.xPath
.compile(String.format("//*[@id='%1$s']", id))
.evaluate(doc, XPathConstants.NODESET))
.item(0);
}
public NodeList getNodeListByTag(Document doc, String tag) throws Exception {
return (NodeList)
(
this.xPath.compile(String.format("//*[contains(name(), '%1$s')]", tag))
.evaluate(doc, XPathConstants.NODESET)
);
}
public Element getFirstElementByTag(Document doc, String tag) throws Exception {
return (Element) this.getNodeListByTag(doc, tag).item(0);
}
public Document parserDocument(File docFile) throws Exception {
Document doc = this.db.parse(docFile);
doc.getDocumentElement().normalize();
return doc;
}
public String unzip(String epubFilePath, String epubDirPath) throws Exception {
ZipInputStream zis = new ZipInputStream(new FileInputStream(epubFilePath));
ZipEntry zipEntry;
int len;
String novelCover = null;
byte[] buffer = new byte[4096];
while ((zipEntry = zis.getNextEntry()) != null) {
if(Pattern.compile(".*cover.*\\.(png|jpeg|jpg)", Pattern.CASE_INSENSITIVE).matcher(zipEntry.getName()).matches()){
novelCover = zipEntry.getName();
}
File newFile = new File(epubDirPath, zipEntry.getName());
newFile.getParentFile().mkdirs();
FileOutputStream fos = new FileOutputStream(newFile);
while ((len = zis.read(buffer)) > 0)
fos.write(buffer, 0, len);
fos.close();
}
zis.closeEntry();
zis.close();
return novelCover;
}
}
@@ -0,0 +1,32 @@
package com.rajarsheechatterjee.EpubParser;
import androidx.annotation.NonNull;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class EpubParserPackage implements ReactPackage {
@NonNull
@Override
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactApplicationContext) {
List<NativeModule> modules = new ArrayList<>();
try {
modules.add(new EpubParser(reactApplicationContext));
} catch (Exception e) {
throw new RuntimeException(e);
}
return modules;
}
@NonNull
@Override
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactApplicationContext) {
return Collections.emptyList();
}
}
@@ -13,7 +13,6 @@ import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate;
import com.facebook.react.ReactRootView;
import com.rajarsheechatterjee.VolumeButtonListener.VolumeButtonListener;
import com.swmansion.gesturehandler.react.RNGestureHandlerEnabledRootView;
import org.devio.rn.splashscreen.SplashScreen;
@@ -11,7 +11,9 @@ import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.soloader.SoLoader;
import com.rajarsheechatterjee.NavigationBarColor.NavigationBarColorPackage;
import com.rajarsheechatterjee.VolumeButtonListener.VolumeButtonListenerPackage;
import com.rajarsheechatterjee.EpubParser.EpubParserPackage;
import expo.modules.ApplicationLifecycleDispatcher;
import expo.modules.ReactNativeHostWrapper;
@@ -21,10 +23,6 @@ import com.swmansion.reanimated.ReanimatedJSIModulePackage;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
import org.devio.rn.splashscreen.SplashScreenReactPackage;
import com.rnfs.RNFSPackage;
public class MainApplication extends Application implements ReactApplication {
private final ReactNativeHost mReactNativeHost =
@@ -36,10 +34,10 @@ public class MainApplication extends Application implements ReactApplication {
@Override
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
packages.add(new com.rajarsheechatterjee.LNReader.NavigationBarColorPackage());
packages.add(new NavigationBarColorPackage());
packages.add(new VolumeButtonListenerPackage());
packages.add(new EpubParserPackage());
return packages;
}
@@ -69,14 +67,6 @@ public class MainApplication extends Application implements ReactApplication {
ApplicationLifecycleDispatcher.onApplicationCreate(this);
}
/**
* Loads Flipper in React Native templates. Call this in the onCreate method
* with something like
* initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
*
* @param context
* @param reactInstanceManager
*/
private static void initializeFlipper(
Context context, ReactInstanceManager reactInstanceManager) {
if (BuildConfig.DEBUG) {
@@ -1,21 +1,18 @@
package com.rajarsheechatterjee.LNReader;
package com.rajarsheechatterjee.NavigationBarColor;
import android.animation.ArgbEvaluator;
import android.animation.ValueAnimator;
import android.annotation.TargetApi;
import android.graphics.Color;
import android.os.Build;
import android.app.Activity;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import androidx.annotation.UiThread;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.UiThreadUtil;
import com.facebook.react.bridge.WritableMap;
import java.util.HashMap;
import java.util.Map;
@@ -1,4 +1,4 @@
package com.rajarsheechatterjee.LNReader;
package com.rajarsheechatterjee.NavigationBarColor;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
+1
View File
@@ -18,6 +18,7 @@ module.exports = function (api) {
'@utils': './src/utils',
'@theme': './src/theme',
'@navigators': './src/navigators',
'@native': './src/native',
},
},
],
+3 -1
View File
@@ -2,6 +2,7 @@ import * as SQLite from 'expo-sqlite';
import {
createCategoriesTableQuery,
createCategoryDefaultQuery,
createCategoryLocalQuery,
createCategoryTriggerQuery,
} from './tables/CategoryTable';
import { createNovelTableQuery } from './tables/NovelTable';
@@ -22,8 +23,9 @@ export const createTables = () => {
db.transaction(tx => {
tx.executeSql(createNovelTableQuery);
tx.executeSql(createCategoriesTableQuery, [], () => {
tx.executeSql(createCategoryTriggerQuery);
tx.executeSql(createCategoryDefaultQuery);
tx.executeSql(createCategoryLocalQuery);
tx.executeSql(createCategoryTriggerQuery);
});
tx.executeSql(createNovelCategoryTableQuery);
tx.executeSql(createChapterTableQuery, [], () => {
+1 -1
View File
@@ -41,7 +41,7 @@ const beforeDeleteCategoryQuery = `
const deleteCategoryQuery = 'DELETE FROM Category WHERE id = ?';
export const deleteCategoryById = (category: Category): void => {
if (category.sort === 1) {
if (category.sort <= 2) {
return showToast('You cant delete default category');
}
db.transaction(tx => {
+1 -1
View File
@@ -5,7 +5,7 @@ import { txnErrorCallback } from '../utils/helpers';
const db = SQLite.openDatabase('lnreader.db');
export const getNovelsWithCatogory = (
export const getNovelsWithCategory = (
categoryId: number,
onlyOngoingNovels?: boolean,
): Promise<NovelInfo[]> => {
+36 -5
View File
@@ -2,6 +2,7 @@ import * as SQLite from 'expo-sqlite';
const db = SQLite.openDatabase('lnreader.db');
import * as DocumentPicker from 'expo-document-picker';
import * as RNFS from 'react-native-fs';
import { fetchChapters, fetchNovel } from '@services/plugin/fetch';
import { insertChapters } from './ChapterQueries';
@@ -12,8 +13,9 @@ import { noop } from 'lodash-es';
import { getString } from '@strings/translations';
import { NovelInfo } from '../types';
import { SourceNovel } from '@plugins/types';
import { NovelDownloadFolder } from '@utils/constants/download';
export const insertNovelandChapters = (
export const insertNovelAndChapters = (
pluginId: string,
sourceNovel: SourceNovel,
): Promise<number> => {
@@ -51,7 +53,7 @@ export const getNovel = async (novelUrl: string): Promise<NovelInfo> => {
return new Promise(resolve =>
db.transaction(tx => {
tx.executeSql(
'SELECT id, * FROM Novel WHERE url = ?',
'SELECT * FROM Novel WHERE url = ?',
[novelUrl],
(txObj, { rows }) => resolve(rows.item(0)),
txnErrorCallback,
@@ -69,6 +71,9 @@ export const switchNovelToLibrary = async (
) => {
const novel = await getNovel(novelUrl);
if (novel) {
if (novel.inLibrary) {
return;
}
db.transaction(tx => {
tx.executeSql(
'UPDATE Novel SET inLibrary = ? WHERE id = ?',
@@ -94,7 +99,7 @@ export const switchNovelToLibrary = async (
});
} else {
const sourceNovel = await fetchNovel(pluginId, novelUrl);
const novelId = await insertNovelandChapters(pluginId, sourceNovel);
const novelId = await insertNovelAndChapters(pluginId, sourceNovel);
db.transaction(tx => {
tx.executeSql(
'UPDATE Novel SET inLibrary = 1 WHERE url = ?',
@@ -112,6 +117,7 @@ export const switchNovelToLibrary = async (
}
};
// allow to delete local novels
export const removeNovelsFromLibrary = (novelIds: Array<number>) => {
db.transaction(tx => {
tx.executeSql(
@@ -124,7 +130,27 @@ export const removeNovelsFromLibrary = (novelIds: Array<number>) => {
showToast('Removed from Library');
};
export const deleteNovelCache = () => {
export const getCachedNovels = (): Promise<NovelInfo[]> => {
return new Promise(resolve => {
db.transaction(tx => {
tx.executeSql(
'SELECT * FROM Novel WHERE inLibrary = 0',
[],
(txObj, { rows }) => resolve(rows._array as NovelInfo[]),
txnErrorCallback,
);
});
});
};
export const deleteCachedNovels = async () => {
const cachedNovels = await getCachedNovels();
for (let novel of cachedNovels) {
const novelDir =
NovelDownloadFolder + '/' + novel.pluginId + '/' + novel.id;
if (await RNFS.exists(novelDir)) {
await RNFS.unlink(novelDir);
}
}
db.transaction(tx => {
tx.executeSql(
'DELETE FROM Novel WHERE inLibrary = 0',
@@ -181,14 +207,17 @@ export const restoreLibrary = async (novel: NovelInfo) => {
export const updateNovelInfo = async (info: NovelInfo) => {
db.transaction(tx => {
tx.executeSql(
'UPDATE Novel SET name = ?, summary = ?, author = ?, artist = ?, genres = ?, status = ? WHERE id = ?',
'UPDATE Novel SET name = ?, cover = ?, url = ? , summary = ?, author = ?, artist = ?, genres = ?, status = ?, isLocal = ? WHERE id = ?',
[
info.name,
info.cover || '',
info.url,
info.summary || '',
info.author || '',
info.artist || '',
info.genres || '',
info.status || '',
info.isLocal,
info.id,
],
noop,
@@ -237,6 +266,8 @@ export const updateNovelCategories = async (
option: string,
): Promise<void> => {
let queries: string[] = [];
// allow local novels have other categories, but not the revesre
categoryIds = categoryIds.filter(id => id !== 2);
if (option === 'KEEP_OLD') {
novelIds.forEach(novelId => {
categoryIds.forEach(categoryId =>
+5 -2
View File
@@ -9,9 +9,12 @@ export const createCategoriesTableQuery = `
export const createCategoryTriggerQuery = `
CREATE TRIGGER IF NOT EXISTS add_category AFTER INSERT ON Category
BEGIN
UPDATE Category SET sort = (SELECT MAX(sort) FROM Category) + 1 WHERE id = new.id;
UPDATE Category SET sort = (SELECT IFNULL(sort, new.id)) WHERE id = new.id;
END;
`;
export const createCategoryDefaultQuery =
'INSERT OR IGNORE INTO Category (id, name, sort) VALUES (IFNULL((SELECT MAX(id) FROM Category), 1), "Default", 0)';
'INSERT OR IGNORE INTO Category (id, name, sort) VALUES (1, "Default", 1)';
export const createCategoryLocalQuery =
'INSERT OR IGNORE INTO Category (id, name, sort) VALUES (2, "Local", 2)';
+2 -1
View File
@@ -10,6 +10,7 @@ export const createNovelTableQuery = `
artist TEXT,
status TEXT Default 'Unknown',
genres TEXT,
inLibrary INTEGER DEFAULT 0
inLibrary INTEGER DEFAULT 0,
isLocal INTERGER DEFAULT 0
);
`;
+1
View File
@@ -11,6 +11,7 @@ export interface NovelInfo {
status?: NovelStatus;
genres?: string;
inLibrary: number;
isLocal: number;
}
export interface LibraryNovelInfo extends NovelInfo {
+8
View File
@@ -0,0 +1,8 @@
import { SourceNovel } from '@plugins/types';
import { NativeModules } from 'react-native';
interface EpubParserInterface {
parse: (epubFilePath: string, epubDirPath: string) => Promise<SourceNovel>;
}
const { EpubParser } = NativeModules;
export default EpubParser as EpubParserInterface;
+1 -1
View File
@@ -149,7 +149,7 @@ const fetchPlugins = async () => {
const githubBranch = 'plugins';
const availablePlugins: Record<Languages, Array<PluginItem>> = await fetch(
`https://raw.githubusercontent.com/${githubUsername}/${githubRepository}/${githubBranch}/dist/${githubUsername}/plugins.min.json`,
`https://raw.githubusercontent.com/${githubUsername}/${githubRepository}/${githubBranch}/.dist/${githubUsername}/plugins.min.json`,
).then(res => res.json());
return availablePlugins;
};
+2 -2
View File
@@ -23,7 +23,7 @@ import {
import { fetchNovel } from '@services/plugin/fetch';
import {
switchNovelToLibrary,
insertNovelandChapters,
insertNovelAndChapters,
getNovel,
} from '@database/queries/NovelQueries';
import { insertChapters } from '@database/queries/ChapterQueries';
@@ -85,7 +85,7 @@ export const getNovelAction =
/**
* Insert novel in db.
*/
await insertNovelandChapters(pluginId, fetchedNovel);
await insertNovelAndChapters(pluginId, fetchedNovel);
/**
* Get novel from db.
*/
+137 -133
View File
@@ -294,6 +294,7 @@ const Novel = ({ route, navigation }) => {
const renderItem = ({ item: it, index }) => (
<ChapterItem
isLocal={novel.isLocal}
theme={theme}
chapter={it}
showChapterTitles={showChapterTitles}
@@ -359,140 +360,143 @@ const Novel = ({ route, navigation }) => {
}}
onPress={() => showJumpToChapterModal(true)}
/>
<Menu
visible={downloadMenu}
onDismiss={() => showDownloadMenu(false)}
anchor={
<IconButton
icon="download-outline"
iconColor={theme.onBackground}
size={24}
style={{
marginTop: StatusBar.currentHeight + 8,
}}
onPress={() => showDownloadMenu(true)}
/>
}
contentStyle={{ backgroundColor: theme.surface2 }}
>
<Menu.Item
title="Next chapter"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
onPress={() => {
dispatch(
downloadAllChaptersAction(novel.pluginId, novel.url, [
chapters.find(
chapter =>
!!chapter.unread === true &&
!!chapter.isDownloaded === false,
),
]),
);
showDownloadMenu(false);
}}
/>
<Menu.Item
title="Next 5 chapter"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() => {
dispatch(
downloadAllChaptersAction(
novel.pluginId,
novel.url,
chapters
.filter(
chapter =>
!!chapter.unread === true &&
!!chapter.idDownloaded === false,
)
.slice(0, 5),
),
);
showDownloadMenu(false);
}}
/>
<Menu.Item
title="Next 10 chapter"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() => {
dispatch(
downloadAllChaptersAction(
novel.pluginId,
novel.url,
chapters
.filter(
chapter =>
!!chapter.unread === true &&
!!chapter.Downloaded === false,
)
.slice(0, 10),
),
);
showDownloadMenu(false);
}}
/>
<Menu.Item
title="Custom"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
onPress={() => {
downloadCustomChapterModal.setTrue();
showDownloadMenu(false);
}}
/>
<Menu.Item
title="Unread"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() => {
dispatch(
downloadAllChaptersAction(
novel.pluginId,
novel.url,
chapters.filter(chapter => !!chapter.unread === true),
),
);
showDownloadMenu(false);
}}
/>
<Menu.Item
title="All"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() => {
dispatch(
downloadAllChaptersAction(
novel.pluginId,
novel.url,
chapters,
),
);
showDownloadMenu(false);
}}
/>
<Menu.Item
title="Delete downloads"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() =>
dispatch(deleteAllChaptersAction(pluginId, chapters))
{!novel.isLocal && (
<Menu
visible={downloadMenu}
onDismiss={() => showDownloadMenu(false)}
anchor={
<IconButton
icon="download-outline"
iconColor={theme.onBackground}
size={24}
style={{
marginTop: StatusBar.currentHeight + 8,
}}
onPress={() => showDownloadMenu(true)}
/>
}
/>
</Menu>
contentStyle={{ backgroundColor: theme.surface2 }}
>
<Menu.Item
title="Next chapter"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
onPress={() => {
dispatch(
downloadAllChaptersAction(novel.pluginId, novel.url, [
chapters.find(
chapter =>
!!chapter.unread === true &&
!!chapter.isDownloaded === false,
),
]),
);
showDownloadMenu(false);
}}
/>
<Menu.Item
title="Next 5 chapter"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() => {
dispatch(
downloadAllChaptersAction(
novel.pluginId,
novel.url,
chapters
.filter(
chapter =>
!!chapter.unread === true &&
!!chapter.idDownloaded === false,
)
.slice(0, 5),
),
);
showDownloadMenu(false);
}}
/>
<Menu.Item
title="Next 10 chapter"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() => {
dispatch(
downloadAllChaptersAction(
novel.pluginId,
novel.url,
chapters
.filter(
chapter =>
!!chapter.unread === true &&
!!chapter.Downloaded === false,
)
.slice(0, 10),
),
);
showDownloadMenu(false);
}}
/>
<Menu.Item
title="Custom"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{ color: theme.onSurface }}
onPress={() => {
downloadCustomChapterModal.setTrue();
showDownloadMenu(false);
}}
/>
<Menu.Item
title="Unread"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() => {
dispatch(
downloadAllChaptersAction(
novel.pluginId,
novel.url,
chapters.filter(
chapter => !!chapter.unread === true,
),
),
);
showDownloadMenu(false);
}}
/>
<Menu.Item
title="All"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() => {
dispatch(
downloadAllChaptersAction(
novel.pluginId,
novel.url,
chapters,
),
);
showDownloadMenu(false);
}}
/>
<Menu.Item
title="Delete downloads"
style={{ backgroundColor: theme.surface2 }}
titleStyle={{
color: theme.onSurface,
}}
onPress={() =>
dispatch(deleteAllChaptersAction(pluginId, chapters))
}
/>
</Menu>
)}
<Menu
visible={extraMenu}
+14 -11
View File
@@ -26,7 +26,7 @@ interface ChapterItemProps {
navigateToChapter: (chapter: ChapterInfo) => void;
showProgressPercentage?: (chapter: ChapterInfo) => any;
left?: ReactNode;
isLocal: boolean;
isUpdateCard?: boolean;
novelName: string;
}
@@ -43,6 +43,7 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
onSelectLongPress,
navigateToChapter,
showProgressPercentage,
isLocal,
left,
isUpdateCard,
novelName,
@@ -124,16 +125,18 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
</View>
</View>
</Row>
<DownloadButton
downloadQueue={downloadQueue}
chapter={chapter}
theme={theme}
deleteChapter={deleteChapter}
downloadChapter={downloadChapter}
hideDeleteChapterMenu={hideDeleteChapterMenu}
showDeleteChapterMenu={showDeleteChapterMenu}
deleteChapterMenuVisible={deleteChapterMenuVisible}
/>
{!isLocal && (
<DownloadButton
downloadQueue={downloadQueue}
chapter={chapter}
theme={theme}
deleteChapter={deleteChapter}
downloadChapter={downloadChapter}
hideDeleteChapterMenu={hideDeleteChapterMenu}
showDeleteChapterMenu={showDeleteChapterMenu}
deleteChapterMenuVisible={deleteChapterMenuVisible}
/>
)}
</Pressable>
);
};
@@ -23,7 +23,7 @@ const NovelScreenButtonGroup: React.FC<NovelScreenButtonGroupProps> = ({
handleFollowNovel,
theme,
}) => {
const { inLibrary } = novel;
const { inLibrary, isLocal } = novel;
const { navigate } = useNavigation();
const followButtonColor = inLibrary ? theme.primary : theme.outline;
@@ -98,7 +98,7 @@ const NovelScreenButtonGroup: React.FC<NovelScreenButtonGroupProps> = ({
</Pressable>
</View>
) : null}
{inLibrary ? (
{inLibrary && !isLocal && (
<View style={styles.buttonContainer}>
<Pressable
android_ripple={{ color: theme.rippleColor }}
@@ -115,23 +115,25 @@ const NovelScreenButtonGroup: React.FC<NovelScreenButtonGroupProps> = ({
</Text>
</Pressable>
</View>
) : null}
<View style={styles.buttonContainer}>
<Pressable
android_ripple={{ color: theme.rippleColor }}
onPress={handleOpenWebView}
style={styles.button}
>
<MaterialCommunityIcons
name="earth"
color={theme.outline}
size={24}
/>
<Text style={[styles.buttonLabel, { color: theme.outline }]}>
WebView
</Text>
</Pressable>
</View>
)}
{!isLocal && (
<View style={styles.buttonContainer}>
<Pressable
android_ripple={{ color: theme.rippleColor }}
onPress={handleOpenWebView}
style={styles.button}
>
<MaterialCommunityIcons
name="earth"
color={theme.outline}
size={24}
/>
<Text style={[styles.buttonLabel, { color: theme.outline }]}>
WebView
</Text>
</Pressable>
</View>
)}
</View>
<Portal>
<SetCategoryModal
@@ -5,7 +5,7 @@ import { Dialog, Portal } from 'react-native-paper';
import { useTheme } from '@hooks/useTheme';
import { showToast } from '@hooks/showToast';
import { deleteNovelCache } from '@database/queries/NovelQueries';
import { deleteCachedNovels } from '@database/queries/NovelQueries';
import { clearCoverCache } from '@services/utils/coverCache';
import { getString } from '@strings/translations';
import useBoolean from '@hooks/useBoolean';
@@ -17,6 +17,7 @@ import {
import { Appbar, Button, List } from '@components';
import useSourceStorage from '@hooks/useSourceStorage';
import { importEpub } from '@services/epub/import';
const AdvancedSettings = ({ navigation }) => {
const theme = useTheme();
@@ -72,6 +73,7 @@ const AdvancedSettings = ({ navigation }) => {
theme={theme}
/>
<List.Item title="Clear cookies" onPress={clearCookies} theme={theme} />
<List.Item title="Import Epub" onPress={importEpub} theme={theme} />
</List.Section>
<Portal>
<ConfirmationDialog
@@ -106,7 +108,7 @@ const AdvancedSettings = ({ navigation }) => {
</Button>
<Button
onPress={() => {
deleteNovelCache();
deleteCachedNovels();
hideClearDatabaseDialog();
}}
>
+268
View File
@@ -0,0 +1,268 @@
import * as SQLite from 'expo-sqlite';
const db = SQLite.openDatabase('lnreader.db');
import { SourceNovel } from '@plugins/types';
import * as DocumentPicker from 'expo-document-picker';
import * as RNFS from 'react-native-fs';
import BackgroundService from 'react-native-background-actions';
import * as Notifications from 'expo-notifications';
import { sleep } from '@utils/sleep';
import EpubParser from '@native/epubParser';
import { NovelDownloadFolder } from '@utils/constants/download';
import dayjs from 'dayjs';
import {
updateNovelCategoryById,
updateNovelInfo,
} from '@database/queries/NovelQueries';
interface TaskData {
delay: number;
epubFilePath: string;
epubDirPath: string; // export dir
}
const insertLocalNovel = (
name: string,
url: string,
cover?: string,
author?: string,
): Promise<number> => {
return new Promise((resolve, reject) => {
db.transaction(tx => {
tx.executeSql(
"INSERT INTO Novel(name, url, cover, author, pluginId, inLibrary, isLocal) VALUES(?, ?, ?, ?, 'local', 1, 1)",
[name, url, cover || '', author || 'unknown'],
async (txObj, resultSet) => {
if (resultSet.insertId) {
await updateNovelCategoryById(resultSet.insertId, [2]);
const novelDir =
NovelDownloadFolder + '/local/' + resultSet.insertId;
await RNFS.mkdir(novelDir);
const newCoverPath =
'file://' + novelDir + '/' + cover?.split(/[\/\\]/).pop();
if (cover && (await RNFS.exists(cover))) {
await RNFS.moveFile(cover, newCoverPath);
}
await updateNovelInfo({
pluginId: 'local',
id: resultSet.insertId,
url: NovelDownloadFolder + '/local/' + resultSet.insertId,
cover: newCoverPath,
name: name,
inLibrary: 1,
isLocal: 1,
});
resolve(resultSet.insertId);
} else {
reject(new Error('novel insert failed'));
}
},
(txObj, error) => {
reject(error);
return false;
},
);
});
});
};
const insertLocalChapter = (
novelId: number,
fakeId: number,
name: string,
url: string,
releaseTime: string,
): Promise<string[]> => {
return new Promise((resolve, reject) => {
db.transaction(tx => {
tx.executeSql(
'INSERT INTO Chapter(novelId, name, url, releaseTime) VALUES(?, ?, ?, ?)',
[
novelId,
name,
// use fakeid just for make the url is unique :D
NovelDownloadFolder + '/local/' + novelId + '/' + fakeId,
releaseTime,
],
async (txObj, resultSet) => {
if (resultSet.insertId) {
let chapterText = await RNFS.readFile(url);
const staticPaths: string[] = [];
const novelDir = NovelDownloadFolder + '/local/' + novelId;
const epubContentDir = url.replace(/[^\\\/]+$/, '');
chapterText = chapterText.replace(
/(href|src)=(["'])(.*?)\2/g,
($0, $1, $2, $3) => {
if ($3) {
staticPaths.push(epubContentDir + '/' + $3);
}
return (
$1 +
'=' +
$2 +
'file://' +
novelDir +
'/' +
$3.split(/[\\\/]/)?.pop() +
$2
);
},
);
await RNFS.mkdir(novelDir + '/' + resultSet.insertId);
await RNFS.writeFile(
novelDir + '/' + resultSet.insertId + '/index.html',
chapterText,
);
resolve(staticPaths);
} else {
reject(new Error('chapter insert failed'));
}
},
(txObj, error) => {
reject(error);
return false;
},
);
});
});
};
const importEpubAction = async (taskData?: TaskData) => {
if (!taskData) {
throw new Error('No data provided');
}
await sleep(taskData.delay);
const novel: SourceNovel = await EpubParser.parse(
taskData.epubFilePath,
taskData.epubDirPath,
);
await sleep(taskData.delay);
try {
const novelId = await insertLocalNovel(
novel.name,
novel.url,
novel.cover,
novel.artist,
).catch(e => {
throw e;
});
const now = dayjs().format('L');
const filePathSet = new Set<string>();
if (novel.chapters) {
BackgroundService.updateNotification({
taskTitle: 'Import Novel',
taskDesc: '0/' + novel.chapters.length,
progressBar: {
value: 0,
max: novel.chapters.length,
},
});
for (let i = 0; i < novel.chapters?.length; i++) {
BackgroundService.updateNotification({
taskDesc: i + 1 + '/' + novel.chapters.length,
progressBar: {
value: i + 1,
max: novel.chapters.length,
},
});
const chapter = novel.chapters[i];
if (chapter.name == null) {
chapter.name = chapter.url.split(/[\\\/]/).pop() || 'unknown';
}
const filePaths = await insertLocalChapter(
novelId,
i,
chapter.name,
chapter.url,
now,
).catch(e => {
throw e;
});
filePaths.forEach(filePath => filePathSet.add(filePath));
await sleep(500);
}
}
await sleep(taskData.delay);
// move static files
const novelDir = NovelDownloadFolder + '/local/' + novelId;
BackgroundService.updateNotification({
taskTitle: 'Import static files',
taskDesc: '0/' + filePathSet.size,
progressBar: {
value: 0,
max: filePathSet.size,
},
});
let cnt = 1;
for (let filePath of filePathSet) {
BackgroundService.updateNotification({
taskDesc: cnt + '/' + filePathSet.size,
progressBar: {
value: cnt,
max: filePathSet.size,
},
});
if (await RNFS.exists(filePath)) {
await RNFS.moveFile(
filePath,
novelDir + '/' + filePath.split(/[\\\/]/).pop(),
);
}
cnt += 1;
}
Notifications.scheduleNotificationAsync({
content: {
title: 'Import Epub',
body: 'Done',
},
trigger: null,
});
} catch (e: any) {
await Notifications.scheduleNotificationAsync({
content: {
title: 'Import error',
body: e.message,
},
trigger: null,
});
await BackgroundService.stop();
}
};
export const importEpub = async () => {
try {
const epubFile = await DocumentPicker.getDocumentAsync({
type: 'application/epub+zip',
copyToCacheDirectory: true,
});
if (epubFile.type === 'cancel') {
throw new Error('Cancel');
}
const epubFilePath = RNFS.ExternalCachesDirectoryPath + '/novel.epub';
await RNFS.moveFile(epubFile.uri, epubFilePath).catch(e => {
throw e;
});
const epubDirPath = RNFS.ExternalCachesDirectoryPath + '/epub';
await RNFS.mkdir(epubDirPath).catch(e => {
throw e;
});
await BackgroundService.start<TaskData>(importEpubAction, {
taskName: 'Import Epub',
taskTitle: 'Parse Epub',
taskDesc: 'Parsing',
taskIcon: { name: 'notification_icon', type: 'drawable' },
color: '#00adb5',
parameters: { delay: 500, epubFilePath, epubDirPath },
});
} catch (e: any) {
// BackgroundService.catch cant catch Error from importEpubAction
// this catch the aboves
// importEpubAction catches itself
await Notifications.scheduleNotificationAsync({
content: {
title: 'Import error',
body: e.message,
},
trigger: null,
});
await BackgroundService.stop();
}
};
+2 -2
View File
@@ -6,7 +6,7 @@ import { store } from '@redux/store';
import { NovelInfo, ChapterInfo } from '@database/types';
import {
getNovel,
insertNovelandChapters,
insertNovelAndChapters,
} from '@database/queries/NovelQueries';
import { getChapters } from '@database/queries/ChapterQueries';
import { downloadChapter } from '@database/queries/ChapterQueries';
@@ -64,7 +64,7 @@ export const migrateNovel = async (
toChapters = await getChapters(toNovel.id, '', '');
} else {
const fetchedNovel = await fetchNovel(pluginId, toNovelUrl);
await insertNovelandChapters(pluginId, fetchedNovel);
await insertNovelAndChapters(pluginId, fetchedNovel);
toNovel = await getNovel(toNovelUrl);
toChapters = await getChapters(toNovel.id, '', '');
}
+2 -2
View File
@@ -2,7 +2,7 @@ import * as Notifications from 'expo-notifications';
import BackgroundService from 'react-native-background-actions';
import {
getNovelsWithCatogory,
getLibraryWithCategory,
getLibraryNovelsFromDb,
} from '../../database/queries/LibraryQueries';
@@ -17,7 +17,7 @@ const updateLibrary = async options => {
let libraryNovels = [];
if (categoryId) {
libraryNovels = await getNovelsWithCatogory(
libraryNovels = await getLibraryWithCategory(
categoryId,
onlyUpdateOngoingNovels,
);
+1
View File
@@ -27,6 +27,7 @@
"@plugins/*": ["src/plugins/*"],
"@services/*": ["src/services/*"],
"@navigators/*": ["src/navigators/*"],
"@native/*": ["src/native/*"],
}
},
"exclude": [