feat: Local EPUB Support (#754)
Closes #38 * Move changes to master branch * Fixed errors stemming from improper move * Add EPub to library * Extract everything in root folder, don't create subfolders * made changes to epubparser.py * changed json metadata file and added some safeguards for different epub formats * Correctly read metadata and convert relative paths * Corrected path for EPUB3 * fixed cover not showing up * Get missing chapters names --------- Co-authored-by: Danisty <danisty.nori@gmail.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "com.chaquo.python"
|
||||
|
||||
import com.android.build.OutputFile
|
||||
|
||||
@@ -146,9 +147,23 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
// Generated version code. Supports versions up to 1024.1024.2048
|
||||
versionCode ((((versionMajor << 10) | versionMinor) << 11) | versionPatch)
|
||||
versionCode((((versionMajor << 10) | versionMinor) << 11) | versionPatch)
|
||||
versionName "$versionMajor.$versionMinor.$versionPatch"
|
||||
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
|
||||
}
|
||||
python {
|
||||
//buildPython "C:/Users/kijij/AppData/Local/Programs/Python/Python39/python.exe" //don't believe you need this
|
||||
pip {
|
||||
install "-r", "./requirements.txt"
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
main {
|
||||
python.srcDirs = ["./src/main/python"]
|
||||
}
|
||||
}
|
||||
}
|
||||
splits {
|
||||
abi {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
lxml==4.6.3
|
||||
@@ -12,7 +12,7 @@ import com.facebook.react.ReactNativeHost;
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.soloader.SoLoader;
|
||||
import com.rajarsheechatterjee.VolumeButtonListener.VolumeButtonListenerPackage;
|
||||
|
||||
import com.rajarsheechatterjee.EpubParser.EpubParserPackage;
|
||||
import expo.modules.ApplicationLifecycleDispatcher;
|
||||
import expo.modules.ReactNativeHostWrapper;
|
||||
|
||||
@@ -40,6 +40,7 @@ public class MainApplication extends Application implements ReactApplication {
|
||||
List<ReactPackage> packages = new PackageList(this).getPackages();
|
||||
packages.add(new com.rajarsheechatterjee.LNReader.NavigationBarColorPackage());
|
||||
packages.add(new VolumeButtonListenerPackage());
|
||||
packages.add(new EpubParserPackage());
|
||||
return packages;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.rajarsheechatterjee.EpubParser;
|
||||
|
||||
import com.chaquo.python.PyObject;
|
||||
import com.chaquo.python.Python;
|
||||
import com.chaquo.python.android.AndroidPlatform;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.content.Context;
|
||||
import android.content.ContentResolver;
|
||||
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.facebook.react.bridge.ActivityEventListener;
|
||||
import com.facebook.react.bridge.BaseActivityEventListener;
|
||||
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.modules.core.DeviceEventManagerModule;
|
||||
import java.io.File;
|
||||
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
public class EpubParser extends ReactContextBaseJavaModule implements ActivityEventListener{
|
||||
private static final int REQUEST_DIRECTORY=100; //constant request code dunno if will use it
|
||||
private static String epubPath;
|
||||
private static Promise promise;
|
||||
private final EpubParserActivityEventListener activityEventListener;
|
||||
private static ReactApplicationContext appContext = null;
|
||||
|
||||
public EpubParser(ReactApplicationContext context){
|
||||
super(context);
|
||||
appContext = context;
|
||||
activityEventListener = new EpubParserActivityEventListener(context, this);
|
||||
context.addActivityEventListener(activityEventListener);
|
||||
if (!Python.isStarted()) {
|
||||
// Initialize Python with the Chaquopy SDK
|
||||
Python.start(new AndroidPlatform(context));
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
|
||||
if (activity != null && requestCode == REQUEST_DIRECTORY && resultCode == Activity.RESULT_OK) {
|
||||
Uri uri = data.getData();
|
||||
String epubPath = getFilePathFromUri(uri);
|
||||
|
||||
Python py = Python.getInstance();
|
||||
PyObject epubParser = py.getModule("epubParser");
|
||||
|
||||
if (epubPath != null && epubPath.toLowerCase().endsWith(".epub")) {
|
||||
PyObject savePath = epubParser.callAttr("parseEpub", epubPath, "/data/data/com.rajarsheechatterjee.LNReader/files/");
|
||||
promise.resolve(savePath.toString());
|
||||
} else {
|
||||
promise.reject("Invalid file format");
|
||||
}
|
||||
} else {
|
||||
promise.reject("Directory selection canceled");
|
||||
}
|
||||
}
|
||||
/*
|
||||
private final ActivityEventListener activityEventListener = new BaseActivityEventListener() {
|
||||
//@Override
|
||||
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data){
|
||||
if(requestCode == REQUEST_DIRECTORY && resultCode == Activity.RESULT_OK){
|
||||
Uri uri = data.getData();
|
||||
String folderPath = getFilePathFromUri(uri);
|
||||
//Log.d("file name", folderPath);
|
||||
epubPath = folderPath;
|
||||
//Log.d("epub path", epubPath);
|
||||
Python py = Python.getInstance();
|
||||
PyObject epubParser = py.getModule("epubParser");
|
||||
if(epubPath != null && epubPath.toLowerCase().endsWith(".epub")){
|
||||
//Log.d("file name", epubPath);
|
||||
PyObject getContent = epubParser.callAttr("parseEpub",epubPath,"/data/data/com.rajarsheechatterjee.LNReader/files/"); //dunno if that's the right app ID
|
||||
epubPath = null;
|
||||
promise.resolve(null);
|
||||
} else{
|
||||
promise.reject("Bad file");
|
||||
}
|
||||
}
|
||||
else{
|
||||
promise.reject("Directory selection canceled");
|
||||
}
|
||||
}
|
||||
};*/
|
||||
|
||||
@Override
|
||||
public String getName(){
|
||||
return "EpubParser";
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void openDirectory(Promise promise){
|
||||
this.promise = promise;
|
||||
try{
|
||||
Intent intent=new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*"); //there's an extra backslash in there
|
||||
final Activity activity = getCurrentActivity();
|
||||
activity.startActivityForResult(intent,REQUEST_DIRECTORY);
|
||||
} catch (Exception e){
|
||||
promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
private String getFilePathFromUri(Uri uri){
|
||||
String filePath=null;
|
||||
if(uri!=null){
|
||||
ContentResolver resolver = getCurrentActivity().getContentResolver();
|
||||
Cursor cursor=resolver.query(uri,null,null,null,null);
|
||||
if(cursor!=null&&cursor.moveToFirst()){
|
||||
int index=cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
String fileName=cursor.getString(index);
|
||||
cursor.close();
|
||||
|
||||
File file=new File(getReactApplicationContext().getCacheDir(),fileName);
|
||||
filePath=file.getAbsolutePath();
|
||||
|
||||
try{
|
||||
InputStream inputStream=resolver.openInputStream(uri);
|
||||
OutputStream outputStream=new FileOutputStream(file);
|
||||
byte[]buffer=new byte[4096];
|
||||
int bytesRead;
|
||||
while((bytesRead=inputStream.read(buffer))!=-1){
|
||||
outputStream.write(buffer,0,bytesRead);
|
||||
}
|
||||
outputStream.close();
|
||||
inputStream.close();
|
||||
}catch(IOException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
@Override
|
||||
public void onNewIntent(Intent intent) {
|
||||
// Handle new intents if needed
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.rajarsheechatterjee.EpubParser;
|
||||
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import com.facebook.react.bridge.ActivityEventListener;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.BaseActivityEventListener;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
|
||||
public class EpubParserActivityEventListener extends BaseActivityEventListener {
|
||||
private final ReactApplicationContext reactContext;
|
||||
private final EpubParser epubParser;
|
||||
|
||||
public EpubParserActivityEventListener(ReactApplicationContext reactContext, EpubParser epubParser) {
|
||||
this.reactContext = reactContext;
|
||||
this.epubParser = epubParser;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
|
||||
epubParser.onActivityResult(activity, requestCode, resultCode, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.rajarsheechatterjee.EpubParser;
|
||||
|
||||
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 {
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
|
||||
List<NativeModule> modules = new ArrayList<NativeModule>();
|
||||
|
||||
modules.add(new EpubParser(reactContext));
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import json
|
||||
import os.path
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from lxml import etree
|
||||
|
||||
# import shutil
|
||||
# from PIL import Image #not necessary only for debugging
|
||||
|
||||
'''export interface SourceNovel {
|
||||
url: string; //must be absoulute
|
||||
name: string;
|
||||
cover?: string;
|
||||
genres?: string;
|
||||
summary?: string;
|
||||
author?: string;
|
||||
artist?: string;
|
||||
status?: string;
|
||||
chapters?: ChapterItem[];
|
||||
}
|
||||
export interface ChapterItem {
|
||||
name: string;
|
||||
url: string; //must be absoulute
|
||||
releaseTime?: string;
|
||||
}'''
|
||||
|
||||
|
||||
class ChapterItem:
|
||||
def __init__(self, name, url, releaseTime):
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.releaseTime = releaseTime # perhaps epub creation date?
|
||||
|
||||
|
||||
namespaces = {
|
||||
'calibre': 'http://calibre.kovidgoyal.net/2009/metadata',
|
||||
'dc': 'http://purl.org/dc/elements/1.1/',
|
||||
'dcterms': 'http://purl.org/dc/terms/',
|
||||
'opf': 'http://www.idpf.org/2007/opf',
|
||||
'ncx': 'http://www.daisy.org/z3986/2005/ncx/',
|
||||
'u': 'urn:oasis:names:tc:opendocument:xmlns:container',
|
||||
'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
|
||||
'xhtml': 'http://www.w3.org/1999/xhtml'
|
||||
}
|
||||
|
||||
|
||||
def getContentOPF(epub_path):
|
||||
with zipfile.ZipFile(epub_path) as z:
|
||||
text = z.read('META-INF/container.xml') # get container xml to get content
|
||||
tree = etree.fromstring(text)
|
||||
rootfile_path = tree.xpath('/u:container/u:rootfiles/u:rootfile',
|
||||
namespaces=namespaces)[0].get('full-path')
|
||||
tree = etree.fromstring(z.read(rootfile_path))
|
||||
return tree
|
||||
|
||||
|
||||
def cleanTitle(dir_name):
|
||||
# cleans a string so that it is valid for directories
|
||||
pattern = r'[<>:"/\\|?*\x00-\x1F]'
|
||||
# Remove any disallowed characters from the directory name string
|
||||
clean_dir_name = re.sub(pattern, '', dir_name)
|
||||
return clean_dir_name
|
||||
|
||||
|
||||
def getContent(epub_path, dest_dir):
|
||||
extensions = ['.html', '.htm', '.xhtml', '.css', '.png', '.jpeg', '.jpg', '.gif', '.ncx',
|
||||
'.opf'] # still need the content.opf file
|
||||
with zipfile.ZipFile(epub_path) as z:
|
||||
for filename in z.namelist():
|
||||
if (filename.endswith('opf')):
|
||||
contentOPF = filename # search for opf file
|
||||
break
|
||||
|
||||
tree = etree.XML(z.read(contentOPF))
|
||||
title = tree.find('.//dc:title', namespaces=namespaces).text
|
||||
cleanedTitle = cleanTitle(title)
|
||||
dir = dest_dir + 'convertedEpubs/' + cleanedTitle # temporary directory for saving
|
||||
|
||||
for fileInfo in z.infolist():
|
||||
if any(fileInfo.filename.endswith(ext) for ext in extensions):
|
||||
z.extract(fileInfo, path=dir)
|
||||
|
||||
return dir
|
||||
|
||||
|
||||
def treeFindsAll(queryString, XMLtree):
|
||||
list = XMLtree.findall(queryString, namespaces=namespaces)
|
||||
query = ''
|
||||
if len(list) == 0:
|
||||
query = 'N/A'
|
||||
return query
|
||||
for item in list:
|
||||
query += (item.text + ' ')
|
||||
return query
|
||||
|
||||
|
||||
class SourceNovel:
|
||||
def __init__(self, url, name, cover, genres, summary, author, artist, chapters):
|
||||
self.url = url # file location?
|
||||
self.name = name
|
||||
self.cover = cover
|
||||
self.genres = genres
|
||||
self.summary = summary
|
||||
self.author = author
|
||||
self.artist = artist
|
||||
self.status = 'local'
|
||||
self.chapters = chapters
|
||||
|
||||
|
||||
def getChapters(z: zipfile.ZipFile, opf_tree, dir_path):
|
||||
isEPUB2 = False
|
||||
chapters = []
|
||||
|
||||
for filename in z.namelist():
|
||||
if filename.endswith('toc.ncx'):
|
||||
isEPUB2 = True
|
||||
chpts_data_filename = filename
|
||||
break
|
||||
|
||||
if isEPUB2:
|
||||
name_by_path = {}
|
||||
tree = etree.XML(z.read(chpts_data_filename))
|
||||
|
||||
for nav_point in tree.xpath('.//ncx:navPoint', namespaces=namespaces):
|
||||
name = nav_point.find('.//ncx:text', namespaces=namespaces).text
|
||||
path = nav_point.find('.//ncx:content', namespaces=namespaces).attrib['src']
|
||||
|
||||
chapter_path = path[:path.index('#')] if '#' in path else path
|
||||
if chapter_path not in name_by_path:
|
||||
name_by_path[chapter_path] = name
|
||||
|
||||
chapters_elements = opf_tree.xpath('//opf:spine//opf:itemref', namespaces=namespaces)
|
||||
for i, chapter_el in enumerate(chapters_elements):
|
||||
id = chapter_el.get('idref')
|
||||
item = opf_tree.find(f".//opf:manifest/opf:item[@id='{id}']", namespaces=namespaces)
|
||||
path = item.attrib['href']
|
||||
chapter_has_name = path in name_by_path
|
||||
print(chapter_has_name)
|
||||
if chapter_has_name:
|
||||
chapter_name = name_by_path[path]
|
||||
if chapter_name.isnumeric():
|
||||
chapterName = f'Chapter {chapter_name}'
|
||||
else:
|
||||
chapterName = chapter_name
|
||||
else:
|
||||
chapterName = id.split('_')[0]
|
||||
|
||||
chapters.append({
|
||||
'name': chapterName,
|
||||
'path': dir_path + '/' + path if len(dir_path) > 0 else path,
|
||||
})
|
||||
else:
|
||||
# EPUB3 uses nav.xhtml instead but I don't have an example rn
|
||||
# For now it will list all files without their corresponding chapter names
|
||||
chapters_elements = opf_tree.xpath('//opf:spine//opf:itemref', namespaces=namespaces)
|
||||
for i, chapter_el in enumerate(chapters_elements):
|
||||
id = chapter_el.get('idref')
|
||||
item = opf_tree.find(f".//opf:manifest/opf:item[@id='{id}']", namespaces=namespaces)
|
||||
path = item.attrib['href']
|
||||
|
||||
chapters.append({
|
||||
'name': f'EPUB3 - Chapter {i + 1}',
|
||||
'path': dir_path + '/' + path if len(dir_path) > 0 else path,
|
||||
})
|
||||
|
||||
return chapters
|
||||
|
||||
|
||||
def getMetadata(epub_path, save_path):
|
||||
with zipfile.ZipFile(epub_path) as z:
|
||||
for filename in z.namelist():
|
||||
if (filename.endswith('opf')):
|
||||
contentOPF = filename # search for opf file
|
||||
break
|
||||
print('content.opf', contentOPF)
|
||||
tree = etree.XML(z.read(contentOPF))
|
||||
|
||||
title = tree.find('.//dc:title', namespaces=namespaces).text
|
||||
# print('title', title)
|
||||
|
||||
cover = getCover(epub_path)
|
||||
# print('cover', cover)
|
||||
|
||||
authors = ''
|
||||
for author in tree.findall('.//dc:creator', namespaces=namespaces):
|
||||
authors += (author.text + ' ')
|
||||
# print('authors', authors)
|
||||
|
||||
genre = treeFindsAll('.//dc:subject', tree)
|
||||
# print('genre', genre)
|
||||
|
||||
summary = treeFindsAll('.//dc:description', tree)
|
||||
# print('summary', summary)
|
||||
|
||||
artist = '' # this will be included with author as epub files do not need to have an artist role and distinction is probably not necessary
|
||||
chapters = getChapters(z, tree, os.path.dirname(contentOPF))
|
||||
print('chapters', chapters)
|
||||
|
||||
return {
|
||||
'url': save_path,
|
||||
'title': title,
|
||||
'cover': cover,
|
||||
'genre': genre,
|
||||
'summary': summary,
|
||||
'authors': authors.strip(),
|
||||
'artist': artist,
|
||||
'chapters': chapters,
|
||||
}
|
||||
|
||||
|
||||
def getCover(epub_path):
|
||||
with zipfile.ZipFile(epub_path) as z:
|
||||
for filename in z.namelist():
|
||||
if (filename.endswith('opf')):
|
||||
contentOPF = filename # search for opf file
|
||||
|
||||
tree = etree.XML(z.read(contentOPF))
|
||||
coverHREF = None
|
||||
|
||||
try:
|
||||
coverID = tree.xpath("//opf:metadata/opf:meta[@name='cover']", namespaces=namespaces)[0].get('content')
|
||||
# print('coverID 2', coverID) # now we know where the cover image is located
|
||||
coverHREF = tree.xpath("//opf:manifest/opf:item[@id='" + coverID + "']", namespaces=namespaces)[0].get(
|
||||
'href')
|
||||
|
||||
except IndexError: # not an EPUB 2.0
|
||||
# print('EPUB 2 failure')
|
||||
pass
|
||||
|
||||
if not coverHREF: # try EPUB 3.0
|
||||
try:
|
||||
coverHREF = tree.xpath("//opf:manifest/opf:item[@properties='cover-image']", namespaces=namespaces)[
|
||||
0].get('href')
|
||||
except IndexError:
|
||||
# print('EPUB 3 failure')
|
||||
pass
|
||||
elif not coverHREF: # some EPUBs don't explicitly declare cover images
|
||||
try:
|
||||
coverID = tree.xpath("//opf:spine/open:itemref[@idref='cover']", namespaces=namespaces)[0].get('idref')
|
||||
temp = tree.xpath("//opf:manifest/opf:item[@id='" + coverID + "']", namespaces=namespaces)[0].get(
|
||||
'href')
|
||||
|
||||
tree = etree.fromstring(z.read(temp))
|
||||
coverHREF = tree.xpath('//xhtml:img', namespaces=namespaces)[0].get('src')
|
||||
except IndexError:
|
||||
print('Edge case failure')
|
||||
elif not coverHREF:
|
||||
# print('No cover found')
|
||||
return None
|
||||
|
||||
coverPath = coverHREF.replace('\\', '/')
|
||||
# print('coverPath', coverPath)
|
||||
|
||||
return coverPath if os.path.dirname(contentOPF) == 0 else os.path.dirname(contentOPF) + '/' + coverPath
|
||||
|
||||
|
||||
def dumpMetaData(metaData, dest_dir):
|
||||
dir = dest_dir + 'convertedEpubs/' + cleanTitle(metaData.get('title')) + '/'
|
||||
with open(dir + 'metadata.json', 'w') as fileout:
|
||||
json.dump(metaData, fileout)
|
||||
|
||||
|
||||
def parseEpub(epub_path, dest_dir):
|
||||
save_path = getContent(epub_path, dest_dir) # simultaneously writes and saves content to a variable
|
||||
metaData = getMetadata(epub_path, save_path)
|
||||
dumpMetaData(metaData, dest_dir)
|
||||
return save_path
|
||||
@@ -20,12 +20,13 @@ buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url "https://chaquo.com/maven" }
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:7.1.2")
|
||||
classpath("com.facebook.react:react-native-gradle-plugin")
|
||||
classpath("de.undercouch:gradle-download-task:5.0.1")
|
||||
|
||||
classpath ("com.chaquo.python:gradle:14.0.2")
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getNovel } from '@database/queries/NovelQueries';
|
||||
import { insertNovelInLibrary } from '@database/queries/NovelQueriesV2';
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
const { EpubParser } = NativeModules;
|
||||
const epubSourceId = 0;
|
||||
|
||||
interface EpubParserInterface {
|
||||
openDirectory(): Promise<string>;
|
||||
}
|
||||
|
||||
export async function openDirectory() {
|
||||
const ParserInterface = EpubParser as EpubParserInterface;
|
||||
const epubPath = await ParserInterface.openDirectory();
|
||||
|
||||
let dbNovel = await getNovel(epubSourceId, epubPath);
|
||||
if (dbNovel === undefined || dbNovel.followed === 0) {
|
||||
// Insert novel if it doesn't exist in the database
|
||||
await insertNovelInLibrary(epubSourceId, epubPath, false, 1);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const sanitizeChapterText = (
|
||||
html: string,
|
||||
options?: Options,
|
||||
): string => {
|
||||
const isEPubSource = options?.sourceId === 0;
|
||||
let text = sanitizeHtml(html, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
|
||||
'img',
|
||||
@@ -25,13 +26,16 @@ export const sanitizeChapterText = (
|
||||
'b',
|
||||
'a',
|
||||
'center',
|
||||
...(isEPubSource ? ['head', 'title', 'style'] : []),
|
||||
]),
|
||||
allowedAttributes: {
|
||||
'img': ['src', 'class'],
|
||||
'a': ['href'],
|
||||
'input': ['type', 'offline'],
|
||||
...(isEPubSource ? { '*': ['class'] } : {}),
|
||||
},
|
||||
allowedSchemes: ['data', 'http', 'https', 'file'],
|
||||
allowVulnerableTags: isEPubSource,
|
||||
});
|
||||
if (text) {
|
||||
if (options?.removeExtraParagraphSpacing) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getString } from '@strings/translations';
|
||||
import useBoolean from '@hooks/useBoolean';
|
||||
import ConfirmationDialog from '@components/ConfirmationDialog/ConfirmationDialog';
|
||||
import { deleteReadChaptersFromDb } from '../../database/queries/DownloadQueries';
|
||||
import { openDirectory } from '../../native/epubParser';
|
||||
|
||||
import { Appbar, Button, List } from '@components';
|
||||
import useSourceStorage from '@hooks/useSourceStorage';
|
||||
@@ -70,6 +71,11 @@ const AdvancedSettings = ({ navigation }) => {
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item title="Clear cookies" onPress={clearCookies} theme={theme} />
|
||||
<List.Item
|
||||
title="Import Epub"
|
||||
onPress={() => openDirectory()}
|
||||
theme={theme}
|
||||
/>
|
||||
</List.Section>
|
||||
<Portal>
|
||||
<ConfirmationDialog
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
import RNFS from 'react-native-fs';
|
||||
import { htmlToText } from '../helpers/htmlToText';
|
||||
|
||||
const sourceName = 'EPub';
|
||||
const sourceId = 0;
|
||||
|
||||
const resolveRelativePath = (absPath: string, relPath: string): string => {
|
||||
const absPathParts = absPath.split('/');
|
||||
const relPathParts = relPath.split('/');
|
||||
|
||||
let backNavigations = 0;
|
||||
relPathParts.forEach(p => p === '..' && backNavigations++);
|
||||
|
||||
const newAbsPath = absPathParts
|
||||
.slice(0, absPathParts.length - backNavigations)
|
||||
.join('/');
|
||||
const newRelPath = relPathParts.slice(backNavigations).join('/');
|
||||
|
||||
return `${newAbsPath}/${newRelPath}`;
|
||||
};
|
||||
|
||||
const parseNovelAndChapters = async (epubPath: string) => {
|
||||
const metadata = JSON.parse(await RNFS.readFile(`${epubPath}/metadata.json`));
|
||||
|
||||
let novel = {
|
||||
sourceId,
|
||||
sourceName,
|
||||
url: epubPath,
|
||||
novelUrl: epubPath,
|
||||
|
||||
novelName: metadata.title,
|
||||
novelCover: `file://${epubPath}/${metadata.cover}`,
|
||||
artist: metadata.artist,
|
||||
author: metadata.authors,
|
||||
summary: htmlToText(metadata.summary),
|
||||
status: 'Completed',
|
||||
|
||||
chapters: metadata.chapters.map((chapter: any) => ({
|
||||
chapterName: chapter.name,
|
||||
chapterUrl: `file://${epubPath}/${chapter.path}`,
|
||||
})),
|
||||
};
|
||||
|
||||
return novel;
|
||||
};
|
||||
|
||||
const parseChapter = async (epubPath: string, chapterUrl: string) => {
|
||||
const metadata = JSON.parse(await RNFS.readFile(`${epubPath}/metadata.json`));
|
||||
const chapterInfo = metadata.chapters.find((f: any) =>
|
||||
chapterUrl.includes(f.path),
|
||||
);
|
||||
|
||||
const dirPath = chapterUrl.substring(0, chapterUrl.lastIndexOf('/'));
|
||||
let chapterText = await RNFS.readFile(chapterUrl);
|
||||
|
||||
const loadedCheerio = cheerio.load(chapterText);
|
||||
const promises: Promise<number>[] = [];
|
||||
|
||||
const imageReplace = function (this: cheerio.Element) {
|
||||
const src =
|
||||
this.attribs.src ??
|
||||
this.attributes.find(
|
||||
attr => attr.name.includes('src') || attr.name.includes('href'),
|
||||
)?.value;
|
||||
|
||||
if (src === undefined) {
|
||||
loadedCheerio(this).remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const promise: Promise<number> = new Promise(async r => {
|
||||
const url = resolveRelativePath(dirPath, src);
|
||||
loadedCheerio(this).replaceWith(`<img src="${url}"/>`);
|
||||
r(0);
|
||||
});
|
||||
|
||||
promises.push(promise);
|
||||
};
|
||||
|
||||
// Specify images absolute path
|
||||
loadedCheerio('img').each(imageReplace);
|
||||
loadedCheerio('image').each(imageReplace);
|
||||
|
||||
// Add styles
|
||||
loadedCheerio('link').each(function () {
|
||||
if (this.attribs.href === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promise: Promise<number> = new Promise(async r => {
|
||||
const path = resolveRelativePath(dirPath, this.attribs.href);
|
||||
const style = await RNFS.readFile(path);
|
||||
loadedCheerio(this).replaceWith(`<style>${style}</style>`);
|
||||
r(0);
|
||||
});
|
||||
|
||||
promises.push(promise);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
chapterText = loadedCheerio.html().replace(/href=".+?"/g, '');
|
||||
|
||||
const chapter = {
|
||||
sourceId,
|
||||
novelUrl: epubPath,
|
||||
chapterUrl,
|
||||
chapterName: chapterInfo.name,
|
||||
chapterText,
|
||||
};
|
||||
|
||||
return chapter;
|
||||
};
|
||||
|
||||
const EPubSource = {
|
||||
parseNovelAndChapters,
|
||||
parseChapter,
|
||||
};
|
||||
|
||||
export default EPubSource;
|
||||
@@ -1,3 +1,4 @@
|
||||
import EPubSource from './local/epubSource';
|
||||
import ComradeMaoScraper from './en/comrademao';
|
||||
import ReadLightNovelScraper from './en/readlightnovel';
|
||||
import fastNovelScraper from './en/fastnovel';
|
||||
@@ -185,6 +186,7 @@ interface Scraper {
|
||||
export const sourceManager = (sourceId: number): Scraper => {
|
||||
const scrapers: Record<number, Scraper> = {
|
||||
// @ts-ignore
|
||||
0: EPubSource, // @ts-ignore
|
||||
1: BoxNovelScraper, // @ts-ignore
|
||||
2: ReadLightNovelScraper, // @ts-ignore
|
||||
3: fastNovelScraper, // @ts-ignore
|
||||
|
||||
Reference in New Issue
Block a user