import { AppPlatform, AppService, ToastType } from '@/types/system'; import { SystemSettings } from '@/types/settings'; import { FileSystem, BaseDir } from '@/types/system'; import { Book, BookConfig, BookContent, BookFormat } from '@/types/book'; import { getDir, getFilename, getBaseFilename, getCoverFilename, getConfigFilename, getLibraryFilename, INIT_BOOK_CONFIG, formatTitle, formatAuthors, } from '@/utils/book'; import { RemoteFile } from '@/utils/file'; import { partialMD5 } from '@/utils/md5'; import { BookDoc, DocumentLoader } from '@/libs/document'; import { DEFAULT_BOOK_LAYOUT, DEFAULT_BOOK_STYLE, DEFAULT_BOOK_FONT, DEFAULT_VIEW_CONFIG, DEFAULT_READSETTINGS, SYSTEM_SETTINGS_VERSION, DEFAULT_BOOK_SEARCH_CONFIG, DEFAULT_TTS_CONFIG, CLOUD_BOOKS_SUBDIR, DEFAULT_MOBILE_VIEW_SETTINGS, DEFAULT_SYSTEM_SETTINGS, DEFAULT_CJK_VIEW_SETTINGS, } from './constants'; import { isWebAppPlatform } from './environment'; import { getOSPlatform, isCJKEnv, isValidURL } from '@/utils/misc'; import { deserializeConfig, serializeConfig } from '@/utils/serializer'; import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage'; import { ProgressHandler } from '@/utils/transfer'; import { TxtToEpubConverter } from '@/utils/txt'; import { BOOK_FILE_NOT_FOUND_ERROR } from './errors'; export abstract class BaseAppService implements AppService { osPlatform: string = getOSPlatform(); localBooksDir: string = ''; abstract fs: FileSystem; abstract appPlatform: AppPlatform; abstract isAppDataSandbox: boolean; abstract isMobile: boolean; abstract isAndroidApp: boolean; abstract isIOSApp: boolean; abstract hasTrafficLight: boolean; abstract hasWindow: boolean; abstract hasWindowBar: boolean; abstract hasContextMenu: boolean; abstract hasRoundedWindow: boolean; abstract hasSafeAreaInset: boolean; abstract hasHaptics: boolean; abstract hasSysFontsList: boolean; abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string }; abstract getCoverImageUrl(book: Book): string; abstract getCoverImageBlobUrl(book: Book): Promise; abstract getInitBooksDir(): Promise; abstract selectFiles(name: string, extensions: string[]): Promise; abstract showMessage( msg: string, kind?: ToastType, title?: string, okLabel?: string, ): Promise; async loadSettings(): Promise { let settings: SystemSettings; const { fp, base } = this.resolvePath('settings.json', 'Settings'); try { await this.fs.exists(fp, base); const txt = await this.fs.readFile(fp, base, 'text'); settings = JSON.parse(txt as string); const version = settings.version ?? 0; if (this.isAppDataSandbox || version < SYSTEM_SETTINGS_VERSION) { settings.localBooksDir = await this.getInitBooksDir(); settings.version = SYSTEM_SETTINGS_VERSION; } settings = { ...DEFAULT_SYSTEM_SETTINGS, ...settings }; settings.globalReadSettings = { ...DEFAULT_READSETTINGS, ...settings.globalReadSettings }; settings.globalViewSettings = { ...DEFAULT_BOOK_LAYOUT, ...DEFAULT_BOOK_STYLE, ...DEFAULT_BOOK_FONT, ...(this.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}), ...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}), ...DEFAULT_VIEW_CONFIG, ...DEFAULT_TTS_CONFIG, ...settings.globalViewSettings, }; } catch { settings = { ...DEFAULT_SYSTEM_SETTINGS, version: SYSTEM_SETTINGS_VERSION, localBooksDir: await this.getInitBooksDir(), globalReadSettings: DEFAULT_READSETTINGS, globalViewSettings: { ...DEFAULT_BOOK_LAYOUT, ...DEFAULT_BOOK_STYLE, ...DEFAULT_BOOK_FONT, ...(this.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}), ...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}), ...DEFAULT_VIEW_CONFIG, ...DEFAULT_TTS_CONFIG, }, } as SystemSettings; await this.fs.createDir('', 'Books', true); await this.fs.createDir('', base, true); await this.fs.writeFile(fp, base, JSON.stringify(settings)); } this.localBooksDir = settings.localBooksDir; return settings; } async saveSettings(settings: SystemSettings): Promise { const { fp, base } = this.resolvePath('settings.json', 'Settings'); await this.fs.createDir('', base, true); await this.fs.writeFile(fp, base, JSON.stringify(settings)); } async importBook( file: string | File, // file path/url or file object books: Book[], saveBook: boolean = true, saveCover: boolean = true, overwrite: boolean = false, transient: boolean = false, ): Promise { try { let loadedBook: BookDoc; let format: BookFormat; let filename: string; let fileobj: File; try { if (typeof file === 'string') { filename = file; fileobj = await new RemoteFile(this.fs.getURL(file), file).open(); } else { filename = file.name; fileobj = file; } if (filename.endsWith('.txt')) { const txt2epub = new TxtToEpubConverter(); ({ file: fileobj } = await txt2epub.convert({ file: fileobj })); } ({ book: loadedBook, format } = await new DocumentLoader(fileobj).open()); if (!loadedBook.metadata.title) { loadedBook.metadata.title = getBaseFilename(filename); } } catch (error) { console.error(error); throw new Error(`Failed to open the book: ${(error as Error).message || error}`); } const hash = await partialMD5(fileobj); const existingBook = books.filter((b) => b.hash === hash)[0]; if (existingBook) { if (!transient) { existingBook.deletedAt = null; } existingBook.updatedAt = Date.now(); } const book: Book = { hash, format, title: formatTitle(loadedBook.metadata.title), author: formatAuthors(loadedBook.metadata.author, loadedBook.metadata.language), createdAt: existingBook ? existingBook.createdAt : Date.now(), uploadedAt: existingBook ? existingBook.uploadedAt : null, deletedAt: transient ? Date.now() : null, downloadedAt: Date.now(), updatedAt: Date.now(), }; if (!(await this.fs.exists(getDir(book), 'Books'))) { await this.fs.createDir(getDir(book), 'Books'); } if (saveBook && (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite)) { if (typeof file === 'string' && !isValidURL(file) && !filename.endsWith('.txt')) { await this.fs.copyFile(file, getFilename(book), 'Books'); } else { await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer()); } } if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) { const cover = await loadedBook.getCover(); if (cover) { await this.fs.writeFile(getCoverFilename(book), 'Books', await cover.arrayBuffer()); } } // Never overwrite the config file only when it's not existed if (!existingBook) { await this.saveBookConfig(book, INIT_BOOK_CONFIG); books.splice(0, 0, book); } else { existingBook.title = book.title; existingBook.author = book.author; } if (typeof file === 'string' && isValidURL(file)) { book.url = file; } book.coverImageUrl = await this.generateCoverImageUrl(book); return book; } catch (error) { throw error; } } async deleteBook(book: Book, includingUploaded = false): Promise { const fps = [getFilename(book), getCoverFilename(book)]; const localDeleteFps = ( await Promise.all(fps.map(async (fp) => ((await this.fs.exists(fp, 'Books')) ? fp : null))) ).filter(Boolean) as string[]; for (const fp of localDeleteFps) { await this.fs.removeFile(fp, 'Books'); } for (const fp of fps) { if (includingUploaded) { console.log('Deleting uploaded file:', fp); const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`; try { deleteFile(cfp); } catch (error) { console.log('Failed to delete uploaded file:', error); } } } book.deletedAt = Date.now(); book.downloadedAt = null; if (includingUploaded) { book.uploadedAt = null; } } async uploadBook(book: Book, onProgress?: ProgressHandler): Promise { let file: File; let uploaded = false; const completedFiles = { count: 0 }; const fps = ( await Promise.all( [getCoverFilename(book), getFilename(book)].map(async (fp) => (await this.fs.exists(fp, 'Books')) ? fp : null, ), ) ).filter(Boolean) as string[]; if (!fps.includes(getFilename(book)) && book.url) { // download the book from the URL const fileobj = await new RemoteFile(book.url).open(); await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer()); fps.push(getFilename(book)); } const handleProgress = createProgressHandler(fps.length, completedFiles, onProgress); for (const fp of fps) { const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`; const fullpath = `${this.localBooksDir}/${fp}`; if (this.appPlatform === 'web') { const content = await this.fs.readFile(fp, 'Books', 'binary'); file = new File([content], cfp); } else { file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), cfp).open(); } console.log('Uploading file:', fp); await uploadFile(file, fullpath, handleProgress, book.hash); uploaded = true; completedFiles.count++; } if (uploaded) { book.deletedAt = null; book.updatedAt = Date.now(); book.uploadedAt = Date.now(); book.downloadedAt = Date.now(); } else { throw new Error('Book file not uploaded'); } } async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise { const fps = onlyCover ? [getCoverFilename(book)] : [getCoverFilename(book), getFilename(book)]; let bookDownloaded = false; const completedFiles = { count: 0 }; const toDownloadFps = ( await Promise.all( [getFilename(book), getCoverFilename(book)].map(async (fp) => (await this.fs.exists(fp, 'Books')) ? null : fp, ), ) ).filter(Boolean) as string[]; const handleProgress = createProgressHandler(toDownloadFps.length, completedFiles, onProgress); for (const fp of fps) { let downloaded = false; const existed = !toDownloadFps.includes(fp); if (existed) { downloaded = true; } else { console.log('Downloading file:', fp); const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`; const fullpath = `${this.localBooksDir}/${fp}`; if (!(await this.fs.exists(getDir(book), 'Books'))) { await this.fs.createDir(getDir(book), 'Books'); } try { const result = await downloadFile(cfp, fullpath, handleProgress); if (isWebAppPlatform()) { const fileobj = result as Blob; await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer()); downloaded = true; } else { downloaded = await this.fs.exists(fp, 'Books'); } } catch { if (fp === getCoverFilename(book)) { console.log('Failed to download cover image:', fp); } else { throw new Error('Failed to download book file'); } } completedFiles.count++; } if (fp === getFilename(book)) { bookDownloaded = downloaded; } } // some books may not have cover image, so we need to check if the book is downloaded if (bookDownloaded) { book.downloadedAt = Date.now(); } } async loadBookContent(book: Book, settings: SystemSettings): Promise { let file: File; const fp = getFilename(book); if (await this.fs.exists(fp, 'Books')) { // TODO: fix random access for android if (this.appPlatform === 'web' || getOSPlatform() === 'android') { const content = await this.fs.readFile(fp, 'Books', 'binary'); file = new File([content], fp); } else { file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open(); } } else if (book.url) { file = await new RemoteFile(book.url).open(); } else { throw new Error(BOOK_FILE_NOT_FOUND_ERROR); } return { book, file, config: await this.loadBookConfig(book, settings) }; } async loadBookConfig(book: Book, settings: SystemSettings): Promise { const { globalViewSettings } = settings; try { let str = '{}'; if (await this.fs.exists(getConfigFilename(book), 'Books')) { str = (await this.fs.readFile(getConfigFilename(book), 'Books', 'text')) as string; } return deserializeConfig(str, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); } catch { return deserializeConfig('{}', globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); } } async fetchBookDetails(book: Book, settings: SystemSettings) { const fp = getFilename(book); if (!(await this.fs.exists(fp, 'Books')) && book.uploadedAt) { await this.downloadBook(book); } const { file } = (await this.loadBookContent(book, settings)) as BookContent; const bookDoc = (await new DocumentLoader(file).open()).book as BookDoc; return bookDoc.metadata; } async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) { let serializedConfig: string; if (settings) { const { globalViewSettings } = settings; serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); } else { serializedConfig = JSON.stringify(config); } await this.fs.writeFile(getConfigFilename(book), 'Books', serializedConfig); } async generateCoverImageUrl(book: Book): Promise { return this.appPlatform === 'web' ? await this.getCoverImageBlobUrl(book) : this.getCoverImageUrl(book); } async loadLibraryBooks(): Promise { console.log('Loading library books...'); let books: Book[] = []; const libraryFilename = getLibraryFilename(); try { const txt = await this.fs.readFile(libraryFilename, 'Books', 'text'); books = JSON.parse(txt as string); } catch { await this.fs.createDir('', 'Books', true); await this.fs.writeFile(libraryFilename, 'Books', '[]'); } await Promise.all( books.map(async (book) => { book.coverImageUrl = await this.generateCoverImageUrl(book); book.updatedAt ??= book.lastUpdated || Date.now(); return book; }), ); return books; } async saveLibraryBooks(books: Book[]): Promise { // eslint-disable-next-line @typescript-eslint/no-unused-vars const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest); await this.fs.writeFile(getLibraryFilename(), 'Books', JSON.stringify(libraryBooks)); } }