diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..2fc98c33 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "packages/foliate-js"] + path = packages/foliate-js + url = git@github.com:johnfactotum/foliate-js.git diff --git a/apps/digest-app/src/libs/document.ts b/apps/digest-app/src/libs/document.ts new file mode 100644 index 00000000..e84f785c --- /dev/null +++ b/apps/digest-app/src/libs/document.ts @@ -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; +} + +export class DocumentLoader { + private file: DocumentFile; + + constructor(file: DocumentFile) { + this.file = file; + } + + private async isZip(): Promise { + 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 { + 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 | 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 }; + } +} diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 8865b597..d4f32a4a 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -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", diff --git a/apps/readest-app/src/app/page.tsx b/apps/readest-app/src/app/page.tsx index fd20d656..9348c78f 100644 --- a/apps/readest-app/src/app/page.tsx +++ b/apps/readest-app/src/app/page.tsx @@ -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 (
- +
- +
+ {libraryBooks.length === 0 && ( +
+
+
+

Your Library

+

+ Welcome to your library. You can upload your books here and read them anytime. +

+ +
+
+
+ )}
); diff --git a/apps/readest-app/src/components/Bookshelf.tsx b/apps/readest-app/src/components/Bookshelf.tsx index 3c80b37e..8165c780 100644 --- a/apps/readest-app/src/components/Bookshelf.tsx +++ b/apps/readest-app/src/components/Bookshelf.tsx @@ -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 = ({ libraryBooks, onImport }) => { - libraryBooks = [...libraryBooks, ...MOCK_BOOKS]; +interface BookshelfProps { + libraryBooks: Book[]; + onImportBooks: () => void; +} + +const Bookshelf: React.FC = ({ libraryBooks, onImportBooks }) => { const bookshelfItems = generateBookshelfItems(libraryBooks); return (
@@ -55,7 +54,7 @@ const Bookshelf: React.FC = ({ libraryBooks, onImport }) => {
{'format' in item ? (
-
+
= ({ libraryBooks, onImport }) => { />
-

{(item as Book).title}

+

{(item as Book).title}

) : (
{(item as BooksGroup).books.map((book) => ( -
+
= ({ libraryBooks, onImport }) => {
diff --git a/apps/readest-app/src/components/Navbar.tsx b/apps/readest-app/src/components/Navbar.tsx index d3dd3d05..05bcd219 100644 --- a/apps/readest-app/src/components/Navbar.tsx +++ b/apps/readest-app/src/components/Navbar.tsx @@ -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 = ({ onImport }) => { +const Navbar: React.FC = ({ onImportBooks }) => { return (
@@ -29,7 +29,7 @@ const Navbar: React.FC = ({ onImport }) => { className='dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow' >
  • - +
  • diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 9735834c..23651e58 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -66,7 +66,7 @@ export const nativeAppService: AppService = { return mode === 'text' ? (readTextFile(fp, base && { baseDir }) as Promise) - : (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`); }, }; diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index d3fc05a7..cc4fb36f 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -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; diff --git a/packages/foliate-js b/packages/foliate-js new file mode 160000 index 00000000..e384aaf6 --- /dev/null +++ b/packages/foliate-js @@ -0,0 +1 @@ +Subproject commit e384aaf6ddb02cd180896aba7e6e4f2da5a34dce diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ecd4b30..e50894a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8ab3e17a..860bb89d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - 'apps/*' + - 'packages/foliate-js' diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..359a9c97 --- /dev/null +++ b/tsconfig.json @@ -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" + } + ] +} \ No newline at end of file