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, } from './constants'; import { isValidURL } from '@/utils/misc'; import { deserializeConfig, serializeConfig } from '@/utils/serializer'; import { downloadFile, uploadFile, deleteFile } from '@/libs/storage'; export abstract class BaseAppService implements AppService { localBooksDir: string = ''; abstract fs: FileSystem; abstract appPlatform: AppPlatform; abstract isAppDataSandbox: boolean; abstract hasTrafficLight: boolean; abstract hasWindowBar: 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.globalReadSettings = { ...DEFAULT_READSETTINGS, ...settings.globalReadSettings }; settings.globalViewSettings = { ...DEFAULT_BOOK_LAYOUT, ...DEFAULT_BOOK_STYLE, ...DEFAULT_BOOK_FONT, ...DEFAULT_VIEW_CONFIG, ...DEFAULT_TTS_CONFIG, ...settings.globalViewSettings, }; } catch { settings = { version: SYSTEM_SETTINGS_VERSION, localBooksDir: await this.getInitBooksDir(), lastSyncedAtBooks: 0, lastSyncedAtConfigs: 0, lastSyncedAtNotes: 0, keepLogin: false, autoUpload: false, globalReadSettings: DEFAULT_READSETTINGS, globalViewSettings: { ...DEFAULT_BOOK_LAYOUT, ...DEFAULT_BOOK_STYLE, ...DEFAULT_BOOK_FONT, ...DEFAULT_VIEW_CONFIG, ...DEFAULT_TTS_CONFIG, }, }; 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, books: Book[], saveBook: boolean = true, saveCover: boolean = true, overwrite: 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; } ({ 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 (existingBook.deletedAt) { existingBook.deletedAt = null; } existingBook.updatedAt = Date.now(); } const book: Book = { hash, format, title: formatTitle(loadedBook.metadata.title), author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author), createdAt: existingBook ? existingBook.createdAt : Date.now(), uploadedAt: existingBook ? existingBook.uploadedAt : 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)) { 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); } 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 { for (const fp of [getFilename(book), getCoverFilename(book)]) { if (await this.fs.exists(fp, 'Books')) { await this.fs.removeFile(fp, 'Books'); } if (includingUploaded) { console.log('Deleting uploaded file:', fp); const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`; await deleteFile(cfp); } } } async uploadBook(book: Book): Promise { let file: File; let uploaded = false; for (const fp of [getFilename(book), getCoverFilename(book)]) { if (await this.fs.exists(fp, 'Books')) { const cfp = `${CLOUD_BOOKS_SUBDIR}/${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, book.hash); uploaded = true; } } if (uploaded) { 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): Promise { const bookFp = getFilename(book); const coverFp = getCoverFilename(book); const fps = [coverFp]; if (!onlyCover) { fps.push(bookFp); } let bookDownloaded = false; for (const fp of fps) { let downloaded = false; const existed = await this.fs.exists(fp, 'Books'); if (existed) { downloaded = true; } else { console.log('Downloading file:', fp); const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`; const fileobj = (await downloadFile(cfp)) as Blob; await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer()); downloaded = true; } if (fp === bookFp) { 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')) { if (this.appPlatform === 'web') { 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'); } 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 { 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)); } }