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:
Isaac H
2023-09-01 22:55:12 -06:00
committed by GitHub
parent 8258059adb
commit f53b0eac80
13 changed files with 646 additions and 3 deletions
+16 -1
View File
@@ -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 {
+1
View File
@@ -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
}
}
@@ -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();
}
}
+268
View File
@@ -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
+2 -1
View File
@@ -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
}
+21
View File
@@ -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
+120
View File
@@ -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;
+2
View File
@@ -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