From b5fc2855f04d6e4c6ad0c4be3e6e46f5b33a5cea Mon Sep 17 00:00:00 2001 From: chrox Date: Mon, 7 Oct 2024 13:10:48 +0200 Subject: [PATCH] Refactor nativeAppService for further appServices --- apps/readest-app/src/services/appService.ts | 203 +++++++++++ apps/readest-app/src/services/environment.ts | 8 +- .../src/services/nativeAppService.ts | 327 ++++++++---------- .../src/services/nativeFileSystem.ts | 104 ------ apps/readest-app/src/types/book.ts | 5 + apps/readest-app/src/types/system.ts | 7 +- 6 files changed, 369 insertions(+), 285 deletions(-) create mode 100644 apps/readest-app/src/services/appService.ts delete mode 100644 apps/readest-app/src/services/nativeFileSystem.ts diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts new file mode 100644 index 00000000..99d305a5 --- /dev/null +++ b/apps/readest-app/src/services/appService.ts @@ -0,0 +1,203 @@ +import { 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, +} from '@/utils/book'; +import { RemoteFile } from '@/utils/file'; +import { partialMD5 } from '@/utils/md5'; +import { BookDoc, DocumentLoader } from '@/libs/document'; + +export abstract class BaseAppService implements AppService { + localBooksDir: string = ''; + abstract isAppDataSandbox: boolean; + abstract fs: FileSystem; + + abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string }; + abstract getCoverImageUrl(book: Book): string; + abstract getInitBooksDir(): Promise; + abstract selectDirectory(title: string): 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); + if (this.isAppDataSandbox) { + settings.localBooksDir = await this.getInitBooksDir(); + } + } catch { + settings = { + localBooksDir: await this.getInitBooksDir(), + globalReadSettings: { + themeType: 'auto', + fontFamily: '', + fontSize: 1.0, + wordSpacing: 0.16, + lineSpacing: 1.5, + }, + }; + + 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[], + 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.isRemoved) { + delete existingBook.isRemoved; + } + existingBook.lastUpdated = Date.now(); + } + + const book: Book = { + hash, + format, + title: loadedBook.metadata.title, + author: loadedBook.metadata.author, + lastUpdated: Date.now(), + }; + book.coverImageUrl = this.getCoverImageUrl(book); + + if (!(await this.fs.exists(getDir(book), 'Books'))) { + await this.fs.createDir(getDir(book), 'Books'); + } + if (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite) { + if (typeof file === 'string') { + await this.fs.copyFile(file, getFilename(book), 'Books'); + } else { + await this.fs.writeFile(getFilename(book), 'Books', await file.arrayBuffer()); + } + } + if (!(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); + } + } catch (error) { + throw error; + } + + return books; + } + + async loadBookContent(book: Book): Promise { + const fp = getFilename(book); + let file: File; + if (fp.endsWith('.pdf')) { + const content = await this.fs.readFile(fp, 'Books', 'binary'); + file = new File([content], fp, { type: 'application/pdf' }); + } else { + file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open(); + } + return { book, file, config: await this.loadBookConfig(book) }; + } + + async loadBookConfig(book: Book): Promise { + try { + const str = await this.fs.readFile(getConfigFilename(book), 'Books', 'text'); + return JSON.parse(str as string); + } catch { + return INIT_BOOK_CONFIG; + } + } + + async saveBookConfig(book: Book, config: BookConfig) { + await this.fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config)); + } + + async loadLibraryBooks(): Promise { + 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.writeFile(libraryFilename, 'Books', '[]'); + } + + books.forEach((book) => { + book.coverImageUrl = this.getCoverImageUrl(book); + }); + + return books; + } + + async saveLibraryBooks(books: Book[]): Promise { + await this.fs.writeFile(getLibraryFilename(), 'Books', JSON.stringify(books)); + } + + async updateLibraryBook(book: Book): Promise { + const library = await this.loadLibraryBooks(); + const bookIndex = library.findIndex((b) => b.hash === book.hash); + if (bookIndex !== -1) { + library[bookIndex] = book; + } + await this.saveLibraryBooks(library); + } +} diff --git a/apps/readest-app/src/services/environment.ts b/apps/readest-app/src/services/environment.ts index eff59610..b971e647 100644 --- a/apps/readest-app/src/services/environment.ts +++ b/apps/readest-app/src/services/environment.ts @@ -1,4 +1,4 @@ -import { AppService } from '../types/system'; +import { AppService } from '@/types/system'; export interface EnvConfigType { appService: () => Promise; @@ -6,8 +6,10 @@ export interface EnvConfigType { const environmentConfig: EnvConfigType = { appService: async () => { - const { nativeAppService } = await import('./nativeAppService'); - return nativeAppService; + const { NativeAppService } = await import('@/services/nativeAppService'); + const appService = new NativeAppService(); + await appService.loadSettings(); + return appService; }, }; diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 7dec7fc1..43693ec6 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -1,199 +1,176 @@ +import { + exists, + mkdir, + readTextFile, + readFile, + writeTextFile, + writeFile, + readDir, + remove, + copyFile, + BaseDirectory, +} from '@tauri-apps/plugin-fs'; import { convertFileSrc } from '@tauri-apps/api/core'; import { open, message } from '@tauri-apps/plugin-dialog'; -import { join, documentDir } from '@tauri-apps/api/path'; +import { join, documentDir, appDataDir } from '@tauri-apps/api/path'; +import { type as osType } from '@tauri-apps/plugin-os'; -import { Book, BookConfig, BookFormat } from '../types/book'; -import { SystemSettings } from '../types/settings'; -import { AppService, ToastType } from '../types/system'; +import { Book } from '@/types/book'; +import { ToastType, FileSystem, BaseDir } from '@/types/system'; +import { getCoverFilename } from '@/utils/book'; + +import { BaseAppService } from './appService'; import { LOCAL_BOOKS_SUBDIR } from './constants'; -import { resolvePath, nativeFileSystem } from './nativeFileSystem'; -import { - getBaseFilename, - getConfigFilename, - getCoverFilename, - getDir, - getFilename, - getLibraryFilename, - INIT_BOOK_CONFIG, -} from '@/utils/book'; -import { BookDoc, DocumentLoader } from '@/libs/document'; -import { RemoteFile } from '@/utils/file'; -import { partialMD5 } from '@/utils/md5'; -let BOOKS_DIR = ''; +export const isMobile = ['android', 'ios'].includes(osType()); -const SETTINGS_PATH = resolvePath('settings.json', 'Settings'); +export const resolvePath = ( + fp: string, + base: BaseDir, +): { baseDir: number; base: BaseDir; fp: string } => { + switch (base) { + case 'Settings': + return { baseDir: BaseDirectory.AppConfig, fp, base }; + case 'Data': + return { baseDir: BaseDirectory.AppData, fp, base }; + case 'Cache': + return { baseDir: BaseDirectory.AppCache, fp, base }; + case 'Log': + return { baseDir: BaseDirectory.AppLog, fp, base }; + case 'Books': + return { + baseDir: isMobile ? BaseDirectory.AppData : BaseDirectory.Document, + fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, + base, + }; + default: + return { + baseDir: BaseDirectory.Temp, + fp, + base, + }; + } +}; -export const nativeAppService: AppService = { - fs: nativeFileSystem, - loadSettings: async () => { - let settings: SystemSettings; - const { fp, base } = SETTINGS_PATH; +export const nativeFileSystem: FileSystem = { + getURL(path: string) { + return convertFileSrc(path); + }, + async copyFile(srcPath: string, dstPath: string, base: BaseDir) { + const { fp, baseDir } = resolvePath(dstPath, base); + await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir }); + }, + async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') { + const { fp, baseDir } = resolvePath(path, base); + + return mode === 'text' + ? (readTextFile(fp, base && { baseDir }) as Promise) + : ((await readFile(fp, base && { baseDir })).buffer as ArrayBuffer); + }, + async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) { + const { fp, baseDir } = resolvePath(path, base); + + return typeof content === 'string' + ? writeTextFile(fp, content, base && { baseDir }) + : writeFile(fp, new Uint8Array(content), base && { baseDir }); + }, + async removeFile(path: string, base: BaseDir) { + const { fp, baseDir } = resolvePath(path, base); + + return remove(fp, base && { baseDir }); + }, + async createDir(path: string, base: BaseDir, recursive = false) { + const { fp, baseDir } = resolvePath(path, base); + + await mkdir(fp, base && { baseDir, recursive }); + }, + async removeDir(path: string, base: BaseDir, recursive = false) { + const { fp, baseDir } = resolvePath(path, base); + + await remove(fp, base && { baseDir, recursive }); + }, + async readDir(path: string, base: BaseDir) { + const { fp, baseDir } = resolvePath(path, base); + + const list = await readDir(fp, base && { baseDir }); + return list.map((entity) => { + return { + path: entity.name, + isDir: entity.isDirectory, + }; + }); + }, + async exists(path: string, base: BaseDir) { + const { fp, baseDir } = resolvePath(path, base); try { - await nativeAppService.fs.exists(fp, base); - const txt = await nativeAppService.fs.readFile(fp, base, 'text'); - settings = JSON.parse(txt as string); + const res = await exists(fp, base && { baseDir }); + return res; } catch { - const INIT_BOOKS_DIR = await join(await documentDir(), LOCAL_BOOKS_SUBDIR); - await nativeAppService.fs.createDir('', 'Books', true); - settings = { - localBooksDir: INIT_BOOKS_DIR, - globalReadSettings: { - themeType: 'auto', - fontFamily: '', - fontSize: 1.0, - wordSpacing: 0.16, - lineSpacing: 1.5, - }, - }; - - await nativeAppService.fs.createDir('', base, true); - await nativeAppService.fs.writeFile(fp, base, JSON.stringify(settings)); + return false; } + }, +}; - BOOKS_DIR = settings.localBooksDir; - return settings; - }, - saveSettings: async (settings: SystemSettings) => { - const { fp, base } = SETTINGS_PATH; - await nativeAppService.fs.createDir('', base, true); - await nativeAppService.fs.writeFile(fp, base, JSON.stringify(settings)); - BOOKS_DIR = settings.localBooksDir; - }, - selectDirectory: async (title: string) => { +export class NativeAppService extends BaseAppService { + fs = nativeFileSystem; + isAppDataSandbox = isMobile; + + resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } { + switch (base) { + case 'Settings': + return { baseDir: BaseDirectory.AppConfig, fp, base }; + case 'Data': + return { baseDir: BaseDirectory.AppData, fp, base }; + case 'Cache': + return { baseDir: BaseDirectory.AppCache, fp, base }; + case 'Log': + return { baseDir: BaseDirectory.AppLog, fp, base }; + case 'Books': + return { + baseDir: BaseDirectory.Document, + fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, + base, + }; + default: + return { + baseDir: BaseDirectory.Temp, + fp, + base, + }; + } + } + + async getInitBooksDir(): Promise { + return join(appDataDir ? await appDataDir() : await documentDir(), LOCAL_BOOKS_SUBDIR); + } + + async selectDirectory(title: string): Promise { const selected = await open({ title, directory: true, }); - return selected as string; - }, - selectFiles: async (name: string, extensions: string[]) => { + } + + async selectFiles(name: string, extensions: string[]): Promise { const selected = await open({ multiple: true, filters: [{ name, extensions }], }); + return Array.isArray(selected) ? selected : selected ? [selected] : []; + } - if (Array.isArray(selected)) { - return selected; - } else if (selected === null) { - return []; - } else { - return [selected]; - } - }, - showMessage: async (msg: string, kind: ToastType = 'info', title?: string, okLabel?: string) => { + async showMessage( + msg: string, + kind: ToastType = 'info', + title?: string, + okLabel?: string, + ): Promise { await message(msg, { kind, title, okLabel }); - }, - importBook: async ( - file: string | File, - books: Book[], - 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(nativeAppService.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.isRemoved) { - delete existingBook.isRemoved; - } - existingBook.lastUpdated = Date.now(); - } - - const book: Book = { - hash, - format, - title: loadedBook.metadata.title, - author: loadedBook.metadata.author, - lastUpdated: Date.now(), - }; - book.coverImageUrl = nativeAppService.getCoverImageUrl(book); - - if (!(await nativeAppService.fs.exists(getDir(book), 'Books'))) { - await nativeAppService.fs.createDir(getDir(book), 'Books'); - } - if (!(await nativeAppService.fs.exists(getFilename(book), 'Books')) || overwrite) { - if (typeof file === 'string') { - await nativeAppService.fs.copyFile(file, getFilename(book), 'Books'); - } else { - await nativeAppService.fs.writeFile(getFilename(book), 'Books', await file.arrayBuffer()); - } - } - if (!(await nativeAppService.fs.exists(getCoverFilename(book), 'Books')) || overwrite) { - const cover = await loadedBook.getCover(); - if (cover) { - await nativeAppService.fs.writeFile( - getCoverFilename(book), - 'Books', - await cover.arrayBuffer(), - ); - } - } - // Never overwrite the config file only when it's not existed - if (!existingBook) { - await nativeAppService.saveBookConfig(book, INIT_BOOK_CONFIG); - books.splice(0, 0, book); - } - } catch (error) { - throw error; - } - - return books; - }, - loadBookConfig: async (book: Book): Promise => { - try { - const str = await nativeAppService.fs.readFile(getConfigFilename(book), 'Books', 'text'); - return JSON.parse(str as string); - } catch { - return INIT_BOOK_CONFIG; - } - }, - saveBookConfig: async (book: Book, config: BookConfig) => { - await nativeAppService.fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config)); - }, - loadLibraryBooks: async () => { - let books: Book[] = []; - const libraryFilename = getLibraryFilename(); - try { - const txt = await nativeAppService.fs.readFile(libraryFilename, 'Books', 'text'); - books = JSON.parse(txt as string); - } catch { - await nativeAppService.fs.writeFile(libraryFilename, 'Books', '[]'); - } - - books.forEach((book) => { - book.coverImageUrl = nativeAppService.getCoverImageUrl(book); - }); - - return books; - }, - saveLibraryBooks: async (books: Book[]) => { - await nativeAppService.fs.writeFile(getLibraryFilename(), 'Books', JSON.stringify(books)); - }, - getCoverImageUrl: (book: Book) => { - return convertFileSrc(`${BOOKS_DIR}/${getCoverFilename(book)}`); - }, -}; + getCoverImageUrl = (book: Book): string => { + return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`); + }; +} diff --git a/apps/readest-app/src/services/nativeFileSystem.ts b/apps/readest-app/src/services/nativeFileSystem.ts deleted file mode 100644 index 5920bddf..00000000 --- a/apps/readest-app/src/services/nativeFileSystem.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - exists, - mkdir, - readTextFile, - readFile, - writeTextFile, - writeFile, - readDir, - remove, - BaseDirectory, - copyFile, -} from '@tauri-apps/plugin-fs'; -import { convertFileSrc } from '@tauri-apps/api/core'; - -import { FileSystem, BaseDir } from '../types/system'; -import { LOCAL_BOOKS_SUBDIR } from './constants'; - -export const resolvePath = ( - fp: string, - base: BaseDir, -): { baseDir: number; base: BaseDir; fp: string } => { - switch (base) { - case 'Settings': - return { baseDir: BaseDirectory.AppConfig, fp, base }; - case 'Data': - return { baseDir: BaseDirectory.AppData, fp, base }; - case 'Cache': - return { baseDir: BaseDirectory.AppCache, fp, base }; - case 'Log': - return { baseDir: BaseDirectory.AppLog, fp, base }; - case 'Books': - return { - baseDir: BaseDirectory.Document, - fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, - base, - }; - default: - return { - baseDir: BaseDirectory.Temp, - fp, - base, - }; - } -}; - -export const nativeFileSystem: FileSystem = { - getURL(path: string) { - return convertFileSrc(path); - }, - async copyFile(srcPath: string, dstPath: string, base: BaseDir) { - const { fp, baseDir } = resolvePath(dstPath, base); - await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir }); - }, - async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') { - const { fp, baseDir } = resolvePath(path, base); - - return mode === 'text' - ? (readTextFile(fp, base && { baseDir }) as Promise) - : ((await readFile(fp, base && { baseDir })).buffer as ArrayBuffer); - }, - async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) { - const { fp, baseDir } = resolvePath(path, base); - - return typeof content === 'string' - ? writeTextFile(fp, content, base && { baseDir }) - : writeFile(fp, new Uint8Array(content), base && { baseDir }); - }, - async removeFile(path: string, base: BaseDir) { - const { fp, baseDir } = resolvePath(path, base); - - return remove(fp, base && { baseDir }); - }, - async createDir(path: string, base: BaseDir, recursive = false) { - const { fp, baseDir } = resolvePath(path, base); - - await mkdir(fp, base && { baseDir, recursive }); - }, - async removeDir(path: string, base: BaseDir, recursive = false) { - const { fp, baseDir } = resolvePath(path, base); - - await remove(fp, base && { baseDir, recursive }); - }, - async readDir(path: string, base: BaseDir) { - const { fp, baseDir } = resolvePath(path, base); - - const list = await readDir(fp, base && { baseDir }); - return list.map((entity) => { - return { - path: entity.name, - isDir: entity.isDirectory, - }; - }); - }, - async exists(path: string, base: BaseDir) { - const { fp, baseDir } = resolvePath(path, base); - - try { - const res = await exists(fp, base && { baseDir }); - return res; - } catch { - return false; - } - }, -}; diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index cc4fb36f..540675e0 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -37,3 +37,8 @@ export interface BooksGroup { books: Book[]; lastUpdated: number; } +export interface BookContent { + book: Book; + file: File; + config: BookConfig; +} diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index a812105b..d2c7c5e5 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -1,5 +1,5 @@ import { SystemSettings } from './settings'; -import { Book, BookConfig } from './book'; +import { Book, BookConfig, BookContent } from './book'; export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None'; export type ToastType = 'info' | 'warning' | 'error'; @@ -19,15 +19,16 @@ export interface FileSystem { export interface AppService { fs: FileSystem; - loadSettings(): Promise; - saveSettings(settings: SystemSettings): Promise; selectDirectory(title: string): Promise; selectFiles(name: string, extensions: string[]): Promise; showMessage(msg: string, kind?: ToastType, title?: string, okLabel?: string): Promise; + loadSettings(): Promise; + saveSettings(settings: SystemSettings): Promise; importBook(file: string | File, books: Book[], overwrite?: boolean): Promise; loadBookConfig(book: Book): Promise; saveBookConfig(book: Book, config: BookConfig): Promise; + loadBookContent(book: Book): Promise; loadLibraryBooks(): Promise; saveLibraryBooks(books: Book[]): Promise; getCoverImageUrl(book: Book): string;