Add books parsing function using foliate-js

This commit is contained in:
chrox
2024-10-04 21:51:23 +02:00
parent b04f7c77cc
commit 01ad5dbb14
12 changed files with 260 additions and 33 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "packages/foliate-js"]
path = packages/foliate-js
url = git@github.com:johnfactotum/foliate-js.git
+145
View File
@@ -0,0 +1,145 @@
import { BookFormat } from '@/types/book';
Object.groupBy ??= (iterable, callbackfn) => {
const obj = Object.create(null);
let i = 0;
for (const value of iterable) {
const key = callbackfn(value, i++);
if (key in obj) {
obj[key].push(value);
} else {
obj[key] = [value];
}
}
return obj;
};
Map.groupBy ??= (iterable, callbackfn) => {
const map = new Map();
let i = 0;
for (const value of iterable) {
const key = callbackfn(value, i++),
list = map.get(key);
if (list) {
list.push(value);
} else {
map.set(key, [value]);
}
}
return map;
};
type DocumentFile = File;
export interface BookDoc {
metadata: {
title: string;
author: string;
editor?: string;
publisher?: string;
};
getCover(): Promise<Blob | null>;
}
export class DocumentLoader {
private file: DocumentFile;
constructor(file: DocumentFile) {
this.file = file;
}
private async isZip(): Promise<boolean> {
const arr = new Uint8Array(await this.file.slice(0, 4).arrayBuffer());
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04;
}
private async isPDF(): Promise<boolean> {
const arr = new Uint8Array(await this.file.slice(0, 5).arrayBuffer());
return (
arr[0] === 0x25 && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46 && arr[4] === 0x2d
);
}
private async makeZipLoader() {
const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = await import(
'@zip.js/zip.js'
);
type Entry = import('@zip.js/zip.js').Entry;
configure({ useWebWorkers: false });
const reader = new ZipReader(new BlobReader(this.file));
const entries = await reader.getEntries();
const map = new Map(entries.map((entry) => [entry.filename, entry]));
const load =
(f: (entry: Entry, type?: string) => Promise<string | Blob> | null) =>
(name: string, ...args: [string?]) =>
map.has(name) ? f(map.get(name)!, ...args) : null;
const loadText = load((entry: Entry) =>
entry.getData ? entry.getData(new TextWriter()) : null,
);
const loadBlob = load((entry: Entry, type?: string) =>
entry.getData ? entry.getData(new BlobWriter(type!)) : null,
);
const getSize = (name: string) => map.get(name)?.uncompressedSize ?? 0;
return { entries, loadText, loadBlob, getSize, sha1: undefined };
}
private isCBZ(): boolean {
return this.file.type === 'application/vnd.comicbook+zip' || this.file.name.endsWith('.cbz');
}
private isFB2(): boolean {
return this.file.type === 'application/x-fictionbook+xml' || this.file.name.endsWith('.fb2');
}
private isFBZ(): boolean {
return (
this.file.type === 'application/x-zip-compressed-fb2' ||
this.file.name.endsWith('.fb2.zip') ||
this.file.name.endsWith('.fbz')
);
}
public async open(): Promise<{ book: BookDoc; format: BookFormat }> {
let book = null;
let format: BookFormat = 'EPUB';
if (!this.file.size) {
throw new Error('File is empty');
}
if (await this.isZip()) {
const loader = await this.makeZipLoader();
const { entries } = loader;
if (this.isCBZ()) {
const { makeComicBook } = await import('foliate-js/comic-book.js');
book = makeComicBook(loader, this.file);
format = 'CBZ';
} else if (this.isFBZ()) {
const entry = entries.find((entry) => entry.filename.endsWith('.fb2'));
const blob = await loader.loadBlob((entry ?? entries[0]!).filename);
const { makeFB2 } = await import('foliate-js/fb2.js');
book = await makeFB2(blob);
format = 'FBZ';
} else {
const { EPUB } = await import('foliate-js/epub.js');
book = await new EPUB(loader).init();
format = 'EPUB';
}
} else if (await this.isPDF()) {
const { makePDF } = await import('foliate-js/pdf.js');
book = await makePDF(this.file);
format = 'PDF';
} else if (await (await import('foliate-js/mobi.js')).isMOBI(this.file)) {
const fflate = await import('foliate-js/vendor/fflate.js');
const { MOBI } = await import('foliate-js/mobi.js');
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(this.file);
format = 'MOBI';
} else if (this.isFB2()) {
const { makeFB2 } = await import('foliate-js/fb2.js');
book = await makeFB2(this.file);
format = 'FB2';
}
return { book, format } as { book: BookDoc; format: BookFormat };
}
}
+2
View File
@@ -15,7 +15,9 @@
"@tauri-apps/plugin-fs": "2.0.0-rc.2",
"@tauri-apps/plugin-log": "2.0.0-rc.1",
"@tauri-apps/plugin-os": "2.0.0-rc.1",
"@zip.js/zip.js": "^2.7.52",
"epubjs": "^0.3.93",
"foliate-js": "workspace:*",
"js-md5": "^0.8.3",
"next": "14.2.13",
"react": "^18",
+27 -4
View File
@@ -41,19 +41,42 @@ const LibraryPage = () => {
});
}, [envConfig, appState]);
const handleImport = () => {
// logic to import books
const handleImportBooks = async () => {
console.log('Importing books...');
const appService = await envConfig.appService();
appService.selectFiles('Select Books', ['epub', 'pdf']).then(async (files) => {
setLoading(true);
for (const file of files) {
await dav?.addBook(file, libraryBooks).then(() => {});
setLibraryBooks(libraryBooks);
}
setLoading(false);
});
};
return (
<div className='min-h-screen bg-gray-100'>
<Navbar onImport={handleImport} />
<Navbar onImportBooks={handleImportBooks} />
<div className='min-h-screen p-2 pt-16'>
<div className='hero-content'>
<Spinner loading={loading} />
<Bookshelf libraryBooks={libraryBooks} onImport={handleImport} />
<Bookshelf libraryBooks={libraryBooks} onImportBooks={handleImportBooks} />
</div>
{libraryBooks.length === 0 && (
<div className='hero min-h-screen'>
<div className='hero-content text-neutral-content text-center'>
<div className='max-w-md'>
<h1 className='mb-5 text-5xl font-bold'>Your Library</h1>
<p className='mb-5'>
Welcome to your library. You can upload your books here and read them anytime.
</p>
<button className='btn btn-primary' onClick={handleImportBooks}>
Upload Books
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
+18 -19
View File
@@ -3,23 +3,18 @@ import Image from 'next/image';
import { Book, BooksGroup } from '../types/book';
import { FaPlus } from 'react-icons/fa';
interface BookshelfProps {
libraryBooks: Book[];
onImport: () => void;
}
type BookshelfItem = Book | BooksGroup;
const UNGROUPED_NAME = 'ungrouped';
const MOCK_BOOKS: Book[] = Array.from({ length: 14 }, (_v, k) => ({
id: `book-${k}`,
format: 'EPUB',
title: `Book ${k}`,
author: `Author ${k}`,
lastUpdated: Date.now() - 1000000 * k,
coverImageUrl: `https://placehold.co/800?text=Book+${k}&font=roboto`,
}));
// const MOCK_BOOKS: Book[] = Array.from({ length: 14 }, (_v, k) => ({
// hash: `book-${k}`,
// format: 'EPUB',
// title: `Book ${k}`,
// author: `Author ${k}`,
// lastUpdated: Date.now() - 1000000 * k,
// coverImageUrl: `https://placehold.co/800?text=Book+${k}&font=roboto`,
// }));
const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
@@ -43,8 +38,12 @@ const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.lastUpdated - a.lastUpdated);
};
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, onImport }) => {
libraryBooks = [...libraryBooks, ...MOCK_BOOKS];
interface BookshelfProps {
libraryBooks: Book[];
onImportBooks: () => void;
}
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, onImportBooks }) => {
const bookshelfItems = generateBookshelfItems(libraryBooks);
return (
<div>
@@ -55,7 +54,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, onImport }) => {
<div className='grid gap-2'>
{'format' in item ? (
<div>
<div key={(item as Book).id} className='card bg-base-100 w-full shadow-md'>
<div key={(item as Book).hash} className='card bg-base-100 w-full shadow-md'>
<Image
width={10}
height={10}
@@ -65,13 +64,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, onImport }) => {
/>
</div>
<div className='card-body p-0 pt-2'>
<h3 className='card-title text-sm'>{(item as Book).title}</h3>
<h3 className='card-title line-clamp-1 text-sm'>{(item as Book).title}</h3>
</div>
</div>
) : (
<div>
{(item as BooksGroup).books.map((book) => (
<div key={book.id} className='card bg-base-100 w-full shadow-md'>
<div key={book.hash} className='card bg-base-100 w-full shadow-md'>
<figure>
<Image
width={10}
@@ -94,7 +93,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, onImport }) => {
<div
className='border-1 flex aspect-[28/41] items-center justify-center bg-white'
role='button'
onClick={onImport}
onClick={onImportBooks}
>
<FaPlus className='size-8' color='gray' />
</div>
+3 -3
View File
@@ -2,10 +2,10 @@ import * as React from 'react';
import { FaSearch, FaPlus } from 'react-icons/fa';
interface NavbarProps {
onImport: () => void;
onImportBooks: () => void;
}
const Navbar: React.FC<NavbarProps> = ({ onImport }) => {
const Navbar: React.FC<NavbarProps> = ({ onImportBooks }) => {
return (
<div className='fixed top-0 z-10 w-full bg-gray-100 p-6'>
<div className='flex items-center justify-between'>
@@ -29,7 +29,7 @@ const Navbar: React.FC<NavbarProps> = ({ onImport }) => {
className='dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow'
>
<li>
<button onClick={onImport}>From Local File</button>
<button onClick={onImportBooks}>From Local File</button>
</li>
</ul>
</span>
@@ -66,7 +66,7 @@ export const nativeAppService: AppService = {
return mode === 'text'
? (readTextFile(fp, base && { baseDir }) as Promise<string>)
: (await readFile(fp, base && { baseDir })).buffer;
: ((await readFile(fp, base && { baseDir })).buffer as ArrayBuffer);
},
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) {
const { fp, baseDir } = resolvePath(path, base);
@@ -188,6 +188,6 @@ export const nativeAppService: AppService = {
return books;
},
generateCoverUrl: (book: Book) => {
return convertFileSrc(`${BOOKS_DIR}/${book.id}/cover.png`);
return convertFileSrc(`${BOOKS_DIR}/${book.hash}/cover.png`);
},
};
+2 -2
View File
@@ -1,7 +1,7 @@
export type BookFormat = 'EPUB' | 'PDF';
export type BookFormat = 'EPUB' | 'PDF' | 'MOBI' | 'CBZ' | 'FB2' | 'FBZ';
export interface Book {
id: string;
hash: string;
format: BookFormat;
title: string;
author: string;
Submodule packages/foliate-js added at e384aaf6dd
+36 -3
View File
@@ -50,9 +50,15 @@ importers:
'@tauri-apps/plugin-os':
specifier: 2.0.0-rc.1
version: 2.0.0-rc.1
'@zip.js/zip.js':
specifier: ^2.7.52
version: 2.7.52
epubjs:
specifier: ^0.3.93
version: 0.3.93
foliate-js:
specifier: workspace:*
version: link:../../packages/foliate-js
js-md5:
specifier: ^0.8.3
version: 0.8.3
@@ -103,6 +109,15 @@ importers:
specifier: ^5
version: 5.6.2
packages/foliate-js:
devDependencies:
'@eslint/js':
specifier: ^9.1.0
version: 9.12.0
globals:
specifier: ^15.0.0
version: 15.11.0
packages:
'@alloc/quick-lru@5.2.0':
@@ -131,6 +146,10 @@ packages:
resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@eslint/js@9.12.0':
resolution: {integrity: sha512-eohesHH8WFRUprDNyEREgqP6beG6htMeUYeCpkEgBCieCMme5r9zFWjzAJp//9S+Kub4rqE+jXe9Cp1a7IYIIA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'}
@@ -448,6 +467,10 @@ packages:
resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
engines: {node: '>=10.0.0'}
'@zip.js/zip.js@2.7.52':
resolution: {integrity: sha512-+5g7FQswvrCHwYKNMd/KFxZSObctLSsQOgqBSi0LzwHo3li9Eh1w5cF5ndjQw9Zbr3ajVnd2+XyiX85gAetx1Q==}
engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1023,6 +1046,10 @@ packages:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
engines: {node: '>=8'}
globals@15.11.0:
resolution: {integrity: sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==}
engines: {node: '>=18'}
globalthis@1.0.4:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
@@ -1998,6 +2025,8 @@ snapshots:
'@eslint/js@8.57.1': {}
'@eslint/js@9.12.0': {}
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -2284,6 +2313,8 @@ snapshots:
'@xmldom/xmldom@0.7.13': {}
'@zip.js/zip.js@2.7.52': {}
acorn-jsx@5.3.2(acorn@8.12.1):
dependencies:
acorn: 8.12.1
@@ -2766,7 +2797,7 @@ snapshots:
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.30.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
eslint-plugin-import: 2.30.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.1)
eslint-plugin-react: 7.36.1(eslint@8.57.1)
eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1)
@@ -2801,7 +2832,7 @@ snapshots:
is-bun-module: 1.2.1
is-glob: 4.0.3
optionalDependencies:
eslint-plugin-import: 2.30.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
eslint-plugin-import: 2.30.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- '@typescript-eslint/parser'
- eslint-import-resolver-node
@@ -2819,7 +2850,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.30.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1):
eslint-plugin-import@2.30.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
@@ -3101,6 +3132,8 @@ snapshots:
dependencies:
type-fest: 0.20.2
globals@15.11.0: {}
globalthis@1.0.4:
dependencies:
define-properties: 1.2.1
+1
View File
@@ -1,2 +1,3 @@
packages:
- 'apps/*'
- 'packages/foliate-js'
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"incremental": true,
"target": "es2022",
"module": "preserve",
"allowJs": true,
"declaration": true,
"strict": true,
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true,
},
"files": [
],
"references": [
{
"path": "./packages"
}
]
}