diff --git a/apps/readest-app/src-tauri/capabilities-extra/webdriver.json b/apps/readest-app/src-tauri/capabilities-extra/webdriver.json index 57717883..9b364128 100644 --- a/apps/readest-app/src-tauri/capabilities-extra/webdriver.json +++ b/apps/readest-app/src-tauri/capabilities-extra/webdriver.json @@ -9,6 +9,19 @@ "permissions": [ "core:default", "fs:default", + "fs:read-all", + "fs:write-all", + "fs:scope-appconfig-recursive", + "fs:scope-appdata-recursive", + { + "identifier": "fs:scope", + "allow": [ + { "path": "**/.readest-test-sandbox-tauri" }, + { "path": "**/.readest-test-sandbox-tauri/**" }, + { "path": "**/Readest" }, + { "path": "**/Readest/**" } + ] + }, "os:default", "dialog:default", "core:window:default", diff --git a/apps/readest-app/src/__tests__/services/import-metahash.test.ts b/apps/readest-app/src/__tests__/services/import-metahash.test.ts new file mode 100644 index 00000000..f20ba404 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/import-metahash.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Book } from '@/types/book'; +import { getMetadataHash } from '@/utils/book'; + +const mockOpen = vi.hoisted(() => vi.fn()); +const mockPartialMD5 = vi.hoisted(() => vi.fn()); + +vi.mock('@/utils/md5', async () => { + const actual = await vi.importActual('@/utils/md5'); + return { ...actual, partialMD5: mockPartialMD5 }; +}); + +vi.mock('@/libs/document', async () => { + const actual = await vi.importActual('@/libs/document'); + class MockDocumentLoader { + open() { + return mockOpen(); + } + } + return { ...actual, DocumentLoader: MockDocumentLoader }; +}); + +vi.mock('@/utils/txt', () => ({ TxtToEpubConverter: vi.fn() })); +vi.mock('@/utils/svg', () => ({ svg2png: vi.fn() })); +vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); +vi.mock('@/libs/storage', () => ({ + downloadFile: vi.fn(), + uploadFile: vi.fn(), + deleteFile: vi.fn(), + createProgressHandler: vi.fn(), + batchGetDownloadUrls: vi.fn(), +})); + +import { BaseAppService } from '@/services/appService'; + +// Concrete test subclass of BaseAppService with mocked fs +class TestAppService extends BaseAppService { + protected fs = { + openFile: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + copyFile: vi.fn(), + removeFile: vi.fn(), + readDir: vi.fn(), + createDir: vi.fn(), + removeDir: vi.fn(), + exists: vi.fn(), + stats: vi.fn(), + resolvePath: vi.fn(), + getURL: vi.fn(), + getBlobURL: vi.fn().mockResolvedValue(''), + getImageURL: vi.fn(), + getPrefix: vi.fn(), + }; + + protected resolvePath() { + return { baseDir: 0, basePrefix: async () => '', fp: '', base: 'Books' as const }; + } + + async init() {} + async setCustomRootDir() {} + async selectDirectory() { + return ''; + } + async selectFiles() { + return []; + } + async saveFile() { + return false; + } + async ask() { + return false; + } + async openDatabase() { + return {} as ReturnType; + } + async createWindow() {} + async getCacheDir() { + return ''; + } + async clearWebviewCache() {} + async showNotification() {} + + getFs() { + return this.fs; + } +} + +function makeBook(overrides: Partial = {}): Book { + return { + hash: 'old-hash-123', + format: 'EPUB' as Book['format'], + title: 'Test Book', + sourceTitle: 'Test Book', + author: 'Test Author', + createdAt: Date.now() - 10000, + updatedAt: Date.now() - 10000, + downloadedAt: Date.now() - 10000, + deletedAt: null, + ...overrides, + }; +} + +const TEST_METADATA = { + title: 'Test Book', + author: 'Test Author', + language: 'en', + identifier: 'isbn-123', +}; + +function setupMockBookDoc(metadata: Record = TEST_METADATA) { + const bookDoc = { + metadata, + getCover: vi.fn().mockResolvedValue(null), + }; + mockOpen.mockResolvedValue({ book: bookDoc, format: 'EPUB' }); +} + +describe('importBook metaHash deduplication', () => { + let service: TestAppService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new TestAppService(); + const fs = service.getFs(); + fs.exists.mockResolvedValue(false); + fs.createDir.mockResolvedValue(undefined); + fs.writeFile.mockResolvedValue(undefined); + fs.removeDir.mockResolvedValue(undefined); + fs.readFile.mockResolvedValue('{}'); + }); + + it('should detect metaHash match and override existing book with new hash', async () => { + const metaHash = getMetadataHash(TEST_METADATA); + + const existingBook = makeBook({ hash: 'old-hash-123', metaHash }); + const books: Book[] = [existingBook]; + + mockPartialMD5.mockResolvedValue('new-hash-456'); + setupMockBookDoc(); + + const mockFile = new File(['new content'], 'test.epub', { type: 'application/epub+zip' }); + const result = await service.importBook(mockFile, books); + + // Should return the existing book, not a new one + expect(result).toBe(existingBook); + // Library should still have only one book + expect(books.length).toBe(1); + // Hash should be updated to new file's content hash + expect(existingBook.hash).toBe('new-hash-456'); + // Metadata should be overridden + expect(existingBook.metadata).toEqual(TEST_METADATA); + // metaHash should be set + expect(existingBook.metaHash).toBe(metaHash); + }); + + it('should not match metaHash for deleted books', async () => { + const metaHash = getMetadataHash(TEST_METADATA); + + const deletedBook = makeBook({ + hash: 'old-hash-123', + metaHash, + deletedAt: Date.now(), + }); + const books: Book[] = [deletedBook]; + + mockPartialMD5.mockResolvedValue('new-hash-456'); + setupMockBookDoc(); + + const mockFile = new File(['new content'], 'test.epub', { type: 'application/epub+zip' }); + const result = await service.importBook(mockFile, books); + + // Should create a new book since the existing one is deleted + expect(result).not.toBe(deletedBook); + expect(books.length).toBe(2); + }); + + it('should migrate config to new directory with updated bookHash and metaHash', async () => { + const metaHash = getMetadataHash(TEST_METADATA); + const existingBook = makeBook({ hash: 'old-hash-123', metaHash }); + const books: Book[] = [existingBook]; + + mockPartialMD5.mockResolvedValue('new-hash-456'); + setupMockBookDoc(); + + const fs = service.getFs(); + fs.exists.mockImplementation(async (path: string) => { + if (path === 'old-hash-123/config.json') return true; + if (path === 'old-hash-123') return true; + return false; + }); + fs.readFile.mockResolvedValue('{"readProgress":0.5}'); + + const mockFile = new File(['new content'], 'test.epub', { type: 'application/epub+zip' }); + await service.importBook(mockFile, books); + + // Should have read config from old directory + expect(fs.readFile).toHaveBeenCalledWith('old-hash-123/config.json', 'Books', 'text'); + // Should have written config to new directory with updated bookHash and metaHash + const writeCalls = fs.writeFile.mock.calls; + const configWrite = writeCalls.find((c: unknown[]) => c[0] === 'new-hash-456/config.json'); + expect(configWrite).toBeDefined(); + const writtenConfig = JSON.parse(configWrite![2] as string); + expect(writtenConfig.bookHash).toBe('new-hash-456'); + expect(writtenConfig.metaHash).toBe(metaHash); + expect(writtenConfig.readProgress).toBe(0.5); + // Should have removed old directory + expect(fs.removeDir).toHaveBeenCalledWith('old-hash-123', 'Books', true); + }); + + it('should prefer exact file hash match over metaHash match', async () => { + const metaHash = getMetadataHash(TEST_METADATA); + + const exactMatchBook = makeBook({ hash: 'same-hash', metaHash }); + const metaMatchBook = makeBook({ hash: 'different-hash', metaHash }); + const books: Book[] = [exactMatchBook, metaMatchBook]; + + mockPartialMD5.mockResolvedValue('same-hash'); + setupMockBookDoc(); + + const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' }); + const result = await service.importBook(mockFile, books); + + // Should return the exact hash match, not the metaHash match + expect(result).toBe(exactMatchBook); + expect(books.length).toBe(2); + }); + + it('should not check metaHash for transient imports', async () => { + const metaHash = getMetadataHash(TEST_METADATA); + const existingBook = makeBook({ hash: 'old-hash', metaHash }); + const books: Book[] = [existingBook]; + + mockPartialMD5.mockResolvedValue('new-hash'); + setupMockBookDoc(); + + const fs = service.getFs(); + fs.openFile.mockResolvedValue(new File(['content'], 'test.epub')); + + // Transient import requires string file path + const result = await service.importBook('/path/to/test.epub', books, true, true, false, true); + + // Should create a new entry, not override existing + expect(result).not.toBe(existingBook); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/node-app-service.test.ts b/apps/readest-app/src/__tests__/services/node-app-service.test.ts new file mode 100644 index 00000000..952a77d5 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/node-app-service.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import * as fsp from 'node:fs/promises'; +import * as path from 'node:path'; +import { NodeAppService } from '@/services/nodeAppService'; +import { fsTests } from './suites/fs-tests'; +import { libraryTests } from './suites/library-tests'; + +const SANDBOX_DIR = path.join(process.cwd(), '.test-sandbox-node'); + +describe('NodeAppService', () => { + let tmpDir: string; + let service: NodeAppService; + + beforeAll(async () => { + await fsp.mkdir(SANDBOX_DIR, { recursive: true }); + }); + + afterAll(async () => { + await fsp.rm(SANDBOX_DIR, { recursive: true, force: true }); + }); + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(SANDBOX_DIR, 'node-')); + service = new NodeAppService(tmpDir); + await service.init(); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('should copy files from absolute path', async () => { + const srcPath = path.join(tmpDir, 'source.txt'); + await fsp.writeFile(srcPath, 'copy me'); + await service.copyFile(srcPath, 'copied.txt', 'Data'); + const content = await service.readFile('copied.txt', 'Data', 'text'); + expect(content).toBe('copy me'); + }); + + it('should save files via saveFile', async () => { + const filepath = path.join(tmpDir, 'saved.txt'); + const result = await service.saveFile('saved.txt', 'saved content', filepath); + expect(result).toBe(true); + const content = await fsp.readFile(filepath, 'utf-8'); + expect(content).toBe('saved content'); + }); + + it('should set localBooksDir after init', () => { + expect(service.localBooksDir).toBe(path.join(tmpDir, 'Readest', 'Books')); + }); + + it('should resolve file paths correctly', async () => { + const resolved = await service.resolveFilePath('test.json', 'Books'); + expect(resolved).toBe(path.join(tmpDir, 'Readest', 'Books', 'test.json')); + }); + + it('should resolve empty path to prefix', async () => { + const resolved = await service.resolveFilePath('', 'Data'); + expect(resolved).toBe(path.join(tmpDir, 'Readest')); + }); + + it('should switch to new root via setCustomRootDir', async () => { + const newRoot = await fsp.mkdtemp(path.join(SANDBOX_DIR, 'custom-')); + try { + await service.setCustomRootDir(newRoot); + expect(service.localBooksDir).toBe(path.join(newRoot, 'Readest', 'Books')); + await service.writeFile('test.txt', 'Settings', 'settings data'); + const content = await service.readFile('test.txt', 'Settings', 'text'); + expect(content).toBe('settings data'); + } finally { + await fsp.rm(newRoot, { recursive: true, force: true }); + } + }); + + it('should use system dirs when no customRootDir', async () => { + const defaultService = new NodeAppService(); + const settingsPrefix = await defaultService.resolveFilePath('', 'Settings'); + expect(settingsPrefix).toBeTruthy(); + expect(path.isAbsolute(settingsPrefix)).toBe(true); + expect(settingsPrefix.toLowerCase()).toContain('readest'); + }); + + fsTests(() => service); + libraryTests(() => service); +}); diff --git a/apps/readest-app/src/__tests__/services/suites/fs-tests.ts b/apps/readest-app/src/__tests__/services/suites/fs-tests.ts new file mode 100644 index 00000000..bafbecd3 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/suites/fs-tests.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { AppService } from '@/types/system'; + +export function fsTests(getService: () => AppService) { + describe('FileSystem', () => { + it('should write and read text files', async () => { + const service = getService(); + await service.writeFile('test.txt', 'Data', 'hello world'); + const content = await service.readFile('test.txt', 'Data', 'text'); + expect(content).toBe('hello world'); + }); + + it('should write and read binary files', async () => { + const service = getService(); + const data = new Uint8Array([1, 2, 3, 4]).buffer; + await service.writeFile('test.bin', 'Data', data); + const content = await service.readFile('test.bin', 'Data', 'binary'); + expect(new Uint8Array(content as ArrayBuffer)).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it('should check file existence', async () => { + const service = getService(); + expect(await service.exists('missing.txt', 'Data')).toBe(false); + await service.writeFile('exists.txt', 'Data', 'content'); + expect(await service.exists('exists.txt', 'Data')).toBe(true); + }); + + it('should delete files', async () => { + const service = getService(); + await service.writeFile('deleteme.txt', 'Data', 'content'); + expect(await service.exists('deleteme.txt', 'Data')).toBe(true); + await service.deleteFile('deleteme.txt', 'Data'); + expect(await service.exists('deleteme.txt', 'Data')).toBe(false); + }); + + it('should create and remove directories', async () => { + const service = getService(); + await service.createDir('sub/nested', 'Data', true); + expect(await service.exists('sub/nested', 'Data')).toBe(true); + await service.deleteDir('sub', 'Data', true); + expect(await service.exists('sub', 'Data')).toBe(false); + }); + + it('should read directory contents recursively', async () => { + const service = getService(); + await service.writeFile('a.txt', 'Data', 'a'); + await service.writeFile('sub/b.txt', 'Data', 'b'); + const items = await service.readDirectory('', 'Data'); + const paths = items.map((i) => i.path).sort(); + expect(paths).toContain('a.txt'); + expect(paths.some((p) => p.endsWith('b.txt'))).toBe(true); + }); + + it('should open files', async () => { + const service = getService(); + await service.writeFile('open.txt', 'Data', 'file content'); + const file = await service.openFile('open.txt', 'Data'); + expect(file.name).toBe('open.txt'); + const text = await file.text(); + expect(text).toBe('file content'); + }); + }); +} diff --git a/apps/readest-app/src/__tests__/services/suites/library-tests.ts b/apps/readest-app/src/__tests__/services/suites/library-tests.ts new file mode 100644 index 00000000..08eccb0f --- /dev/null +++ b/apps/readest-app/src/__tests__/services/suites/library-tests.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { Book } from '@/types/book'; +import { AppService } from '@/types/system'; + +function makeBook(overrides: Partial = {}): Book { + return { + hash: 'abc123', + format: 'EPUB', + title: 'Test Book', + author: 'Test Author', + createdAt: 1000, + updatedAt: 2000, + ...overrides, + }; +} + +export function libraryTests(getService: () => AppService) { + describe('Library', () => { + it('should return empty array when no library file exists', async () => { + const books = await getService().loadLibraryBooks(); + expect(books).toEqual([]); + }); + + it('should save and load a single book', async () => { + const service = getService(); + const book = makeBook(); + await service.saveLibraryBooks([book]); + + const loaded = await service.loadLibraryBooks(); + expect(loaded).toHaveLength(1); + expect(loaded[0]!.hash).toBe('abc123'); + expect(loaded[0]!.title).toBe('Test Book'); + expect(loaded[0]!.author).toBe('Test Author'); + }); + + it('should save and load multiple books', async () => { + const service = getService(); + const books = [ + makeBook({ hash: 'h1', title: 'Book One' }), + makeBook({ hash: 'h2', title: 'Book Two' }), + makeBook({ hash: 'h3', title: 'Book Three' }), + ]; + await service.saveLibraryBooks(books); + + const loaded = await service.loadLibraryBooks(); + expect(loaded).toHaveLength(3); + const titles = loaded.map((b) => b.title).sort(); + expect(titles).toEqual(['Book One', 'Book Three', 'Book Two']); + }); + + it('should strip coverImageUrl when saving', async () => { + const service = getService(); + const book = makeBook({ coverImageUrl: 'http://example.com/cover.jpg' }); + await service.saveLibraryBooks([book]); + + const raw = (await service.readFile('library.json', 'Books', 'text')) as string; + const parsed = JSON.parse(raw) as Book[]; + expect(parsed[0]!.coverImageUrl).toBeUndefined(); + }); + + it('should create backup file alongside main file', async () => { + const service = getService(); + await service.saveLibraryBooks([makeBook()]); + + expect(await service.exists('library.json', 'Books')).toBe(true); + expect(await service.exists('library.json.bak', 'Books')).toBe(true); + }); + + it('should fall back to backup when main file is corrupted', async () => { + const service = getService(); + const book = makeBook({ hash: 'good' }); + await service.saveLibraryBooks([book]); + + // Corrupt the main file + await service.writeFile('library.json', 'Books', '{corrupted data!!!'); + + const loaded = await service.loadLibraryBooks(); + expect(loaded).toHaveLength(1); + expect(loaded[0]!.hash).toBe('good'); + }); + + it('should return empty array when both main and backup are corrupted', async () => { + const service = getService(); + await service.saveLibraryBooks([makeBook()]); + + await service.writeFile('library.json', 'Books', 'bad'); + await service.writeFile('library.json.bak', 'Books', 'bad'); + + const loaded = await service.loadLibraryBooks(); + expect(loaded).toEqual([]); + }); + + it('should overwrite previous library on save', async () => { + const service = getService(); + await service.saveLibraryBooks([makeBook({ hash: 'old' })]); + + const newBooks = [makeBook({ hash: 'new1' }), makeBook({ hash: 'new2' })]; + await service.saveLibraryBooks(newBooks); + + const loaded = await service.loadLibraryBooks(); + expect(loaded).toHaveLength(2); + const hashes = loaded.map((b) => b.hash).sort(); + expect(hashes).toEqual(['new1', 'new2']); + }); + + it('should preserve all book fields through round-trip', async () => { + const service = getService(); + const book = makeBook({ + hash: 'full', + title: 'Full Book', + author: 'Full Author', + format: 'PDF', + tags: ['fiction', 'sci-fi'], + groupName: 'Series A', + progress: [5, 100], + createdAt: 1000, + updatedAt: 2000, + }); + await service.saveLibraryBooks([book]); + + const loaded = await service.loadLibraryBooks(); + expect(loaded[0]!.hash).toBe('full'); + expect(loaded[0]!.format).toBe('PDF'); + expect(loaded[0]!.tags).toEqual(['fiction', 'sci-fi']); + expect(loaded[0]!.groupName).toBe('Series A'); + expect(loaded[0]!.progress).toEqual([5, 100]); + }); + + it('should set updatedAt from lastUpdated for legacy books', async () => { + const service = getService(); + // Write a legacy book JSON with lastUpdated but no updatedAt + const legacyBook = { + hash: 'legacy', + format: 'EPUB', + title: 'Old', + author: 'A', + createdAt: 1000, + lastUpdated: 5000, + }; + await service.writeFile('library.json', 'Books', JSON.stringify([legacyBook])); + + const loaded = await service.loadLibraryBooks(); + expect(loaded[0]!.updatedAt).toBe(5000); + }); + + it('should save empty array', async () => { + const service = getService(); + await service.saveLibraryBooks([makeBook()]); + await service.saveLibraryBooks([]); + + const loaded = await service.loadLibraryBooks(); + expect(loaded).toEqual([]); + }); + }); +} diff --git a/apps/readest-app/src/__tests__/services/tauri-app-service.tauri.test.ts b/apps/readest-app/src/__tests__/services/tauri-app-service.tauri.test.ts new file mode 100644 index 00000000..463266e6 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tauri-app-service.tauri.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { join } from '@tauri-apps/api/path'; +import { mkdir, remove, writeTextFile, readTextFile } from '@tauri-apps/plugin-fs'; +import { NativeAppService } from '@/services/nativeAppService'; +import { fsTests } from './suites/fs-tests'; +import { libraryTests } from './suites/library-tests'; + +const SANDBOX_DIR = `${process.env['CWD']}/.readest-test-sandbox-tauri`; +let tmpCounter = 0; + +describe('NativeAppService', () => { + let tmpDir: string; + let service: NativeAppService; + + beforeAll(async () => { + await mkdir(SANDBOX_DIR, { recursive: true }); + }); + + afterAll(async () => { + await remove(SANDBOX_DIR, { recursive: true }); + }); + + beforeEach(async () => { + tmpCounter++; + tmpDir = await join(SANDBOX_DIR, `tauri-${Date.now()}-${tmpCounter}`); + await mkdir(tmpDir, { recursive: true }); + service = new NativeAppService(tmpDir); + await service.init(); + // init() doesn't create base dirs; ensure Data and Books dirs exist + await service.createDir('', 'Data', true); + await service.createDir('', 'Books', true); + }); + + afterEach(async () => { + await remove(tmpDir, { recursive: true }); + }); + + it('should have localBooksDir set after init', () => { + expect(service.localBooksDir).toBeTruthy(); + expect(service.localBooksDir.toLowerCase()).toContain('books'); + }); + + it('should resolve file paths to absolute paths', async () => { + const resolved = await service.resolveFilePath('test.json', 'Books'); + expect(resolved).toBeTruthy(); + expect(resolved).toContain('test.json'); + expect(resolved.toLowerCase()).toContain('books'); + }); + + it('should have appPlatform set to tauri', () => { + expect(service.appPlatform).toBe('tauri'); + }); + + it('should write and read text via plugin wrapper', async () => { + const filePath = await join(tmpDir, 'wrapper-test.txt'); + await writeTextFile(filePath, 'wrapper-write'); + const content = await readTextFile(filePath); + expect(content).toBe('wrapper-write'); + }); + + fsTests(() => service); + libraryTests(() => service); +}); diff --git a/apps/readest-app/src/__tests__/services/web-app-service.browser.test.ts b/apps/readest-app/src/__tests__/services/web-app-service.browser.test.ts new file mode 100644 index 00000000..8bbb9cea --- /dev/null +++ b/apps/readest-app/src/__tests__/services/web-app-service.browser.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { WebAppService } from '@/services/webAppService'; +import { fsTests } from './suites/fs-tests'; +import { libraryTests } from './suites/library-tests'; + +/** Clear all records from the IndexedDB object store without deleting the database. */ +async function clearStore() { + return new Promise((resolve, reject) => { + const request = indexedDB.open('AppFileSystem', 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains('files')) { + db.createObjectStore('files', { keyPath: 'path' }); + } + }; + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('files', 'readwrite'); + tx.objectStore('files').clear(); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }; + request.onerror = () => reject(request.error); + }); +} + +describe('WebAppService', () => { + let service: WebAppService; + + beforeEach(async () => { + await clearStore(); + service = new WebAppService(); + await service.init(); + }); + + it('should resolve file paths with base prefix', async () => { + const resolved = await service.resolveFilePath('test.json', 'Books'); + expect(resolved).toBe('Readest/Books/test.json'); + }); + + it('should resolve empty Data path to prefix', async () => { + const resolved = await service.resolveFilePath('', 'Data'); + expect(resolved).toBe('Readest'); + }); + + it('should set localBooksDir after init', () => { + expect(service.localBooksDir).toBe('Readest/Books'); + }); + + fsTests(() => service); + libraryTests(() => service); +}); diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 066e753b..8c223d40 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -1,90 +1,32 @@ -import { v4 as uuidv4 } from 'uuid'; import { SystemSettings } from '@/types/settings'; import { AppPlatform, AppService, + BaseDir, + DeleteAction, DistChannel, FileItem, + FileSystem, OsPlatform, ResolvedPath, SelectDirectoryMode, } from '@/types/system'; -import { FileSystem, BaseDir, DeleteAction } from '@/types/system'; import { DatabaseOpts, DatabaseService } from '@/types/database'; import { SchemaType } from '@/services/database/migrate'; -import { - Book, - BookConfig, - BookContent, - BookFormat, - FIXED_LAYOUT_FORMATS, - ViewSettings, -} from '@/types/book'; -import { - getDir, - getLocalBookFilename, - getRemoteBookFilename, - getCoverFilename, - getConfigFilename, - getLibraryFilename, - INIT_BOOK_CONFIG, - formatTitle, - formatAuthors, - getPrimaryLanguage, - getLibraryBackupFilename, -} from '@/utils/book'; -import { md5, partialMD5 } from '@/utils/md5'; -import { getBaseFilename, getFilename } from '@/utils/path'; -import { BookDoc, DocumentLoader, EXTS } from '@/libs/document'; -import { - DEFAULT_BOOK_LAYOUT, - DEFAULT_BOOK_STYLE, - DEFAULT_BOOK_FONT, - DEFAULT_BOOK_LANGUAGE, - 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, - DEFAULT_MOBILE_READSETTINGS, - DEFAULT_SCREEN_CONFIG, - DEFAULT_TRANSLATOR_CONFIG, - DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS, - SETTINGS_FILENAME, - DEFAULT_MOBILE_SYSTEM_SETTINGS, - DEFAULT_ANNOTATOR_CONFIG, - DEFAULT_EINK_VIEW_SETTINGS, -} from './constants'; -import { DEFAULT_AI_SETTINGS } from './ai/constants'; -import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; -import { - getOSPlatform, - getTargetLang, - isCJKEnv, - isContentURI, - isValidURL, - makeSafeFilename, -} from '@/utils/misc'; -import { deserializeConfig, serializeConfig } from '@/utils/serializer'; -import { - downloadFile, - uploadFile, - deleteFile, - createProgressHandler, - batchGetDownloadUrls, -} from '@/libs/storage'; -import { ClosableFile } from '@/utils/file'; +import { Book, BookConfig, BookContent, ViewSettings } from '@/types/book'; +import { getLibraryFilename, getLibraryBackupFilename } from '@/utils/book'; + +import { getOSPlatform } from '@/utils/misc'; import { ProgressHandler } from '@/utils/transfer'; -import { TxtToEpubConverter } from '@/utils/txt'; -import { BookFileNotFoundError } from './errors'; import { CustomTextureInfo } from '@/styles/textures'; import { CustomFont, CustomFontInfo } from '@/styles/fonts'; -import { parseFontInfo } from '@/utils/font'; -import { svg2png } from '@/utils/svg'; + +import * as BookSvc from './bookService'; +import * as CloudSvc from './cloudService'; +import * as FontSvc from './fontService'; +import * as ImageSvc from './imageService'; +import * as LibrarySvc from './libraryService'; +import * as Settings from './settingsService'; export abstract class BaseAppService implements AppService { osPlatform: OsPlatform = getOSPlatform(); @@ -151,6 +93,22 @@ export abstract class BaseAppService implements AppService { } } + private async migrate20251124(): Promise { + console.log('Running migration for version 20251124 to rename the backup library file...'); + const oldBackupFilename = getLibraryBackupFilename(); + const newBackupFilename = `${getLibraryFilename()}.bak`; + if (await this.fs.exists(oldBackupFilename, 'Books')) { + try { + const content = await this.fs.readFile(oldBackupFilename, 'Books', 'text'); + await this.fs.writeFile(newBackupFilename, 'Books', content); + await this.fs.removeFile(oldBackupFilename, 'Books'); + console.log('Migration to rename backup library file completed successfully.'); + } catch (error) { + console.error('Error during migration to rename backup library file:', error); + } + } + } + async prepareBooksDir() { this.localBooksDir = await this.fs.getPrefix('Books'); } @@ -200,160 +158,67 @@ export abstract class BaseAppService implements AppService { return await this.fs.getImageURL(path); } - getCoverImageUrl = (book: Book): string => { - return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`); - }; + private get settingsCtx(): Settings.Context { + return { + fs: this.fs, + isMobile: this.isMobile, + isEink: this.isEink, + isAppDataSandbox: this.isAppDataSandbox, + }; + } - getCoverImageBlobUrl = async (book: Book): Promise => { - return this.fs.getBlobURL(`${this.localBooksDir}/${getCoverFilename(book)}`, 'None'); - }; - - async getCachedImageUrl(pathOrUrl: string): Promise { - const cachedKey = `img_${md5(pathOrUrl)}`; - const cachePrefix = await this.fs.getPrefix('Cache'); - const cachedPath = `${cachePrefix}/${cachedKey}`; - if (await this.fs.exists(cachedPath, 'None')) { - return await this.fs.getImageURL(cachedPath); - } else { - const file = await this.fs.openFile(pathOrUrl, 'None'); - await this.fs.writeFile(cachedKey, 'Cache', await file.arrayBuffer()); - return await this.fs.getImageURL(cachedPath); - } + private get coverCtx(): BookSvc.CoverContext { + return { fs: this.fs, appPlatform: this.appPlatform, localBooksDir: this.localBooksDir }; } getDefaultViewSettings(): ViewSettings { - return { - ...DEFAULT_BOOK_LAYOUT, - ...DEFAULT_BOOK_STYLE, - ...DEFAULT_BOOK_FONT, - ...DEFAULT_BOOK_LANGUAGE, - ...(this.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}), - ...(this.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}), - ...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}), - ...DEFAULT_VIEW_CONFIG, - ...DEFAULT_TTS_CONFIG, - ...DEFAULT_SCREEN_CONFIG, - ...DEFAULT_ANNOTATOR_CONFIG, - ...{ ...DEFAULT_TRANSLATOR_CONFIG, translateTargetLang: getTargetLang() }, - }; + return Settings.getDefaultViewSettings(this.settingsCtx); } async loadSettings(): Promise { - const defaultSettings: SystemSettings = { - ...DEFAULT_SYSTEM_SETTINGS, - ...(this.isMobile ? DEFAULT_MOBILE_SYSTEM_SETTINGS : {}), - version: SYSTEM_SETTINGS_VERSION, - localBooksDir: await this.fs.getPrefix('Books'), - koreaderSyncDeviceId: uuidv4(), - globalReadSettings: { - ...DEFAULT_READSETTINGS, - ...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}), - }, - globalViewSettings: this.getDefaultViewSettings(), - } as SystemSettings; - - let settings = await this.safeLoadJSON( - SETTINGS_FILENAME, - 'Settings', - defaultSettings, - ); - - const version = settings.version ?? 0; - if (this.isAppDataSandbox || version < SYSTEM_SETTINGS_VERSION) { - settings.version = SYSTEM_SETTINGS_VERSION; - } - settings = { - ...DEFAULT_SYSTEM_SETTINGS, - ...(this.isMobile ? DEFAULT_MOBILE_SYSTEM_SETTINGS : {}), - ...settings, - }; - settings.globalReadSettings = { - ...DEFAULT_READSETTINGS, - ...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}), - ...settings.globalReadSettings, - }; - settings.globalViewSettings = { - ...this.getDefaultViewSettings(), - ...settings.globalViewSettings, - }; - settings.aiSettings = { - ...DEFAULT_AI_SETTINGS, - ...settings.aiSettings, - }; - - settings.localBooksDir = await this.fs.getPrefix('Books'); - - if (!settings.kosync.deviceId) { - settings.kosync.deviceId = uuidv4(); - await this.saveSettings(settings); - } - + const settings = await Settings.loadSettings(this.settingsCtx); this.localBooksDir = settings.localBooksDir; return settings; } async saveSettings(settings: SystemSettings): Promise { - await this.safeSaveJSON(SETTINGS_FILENAME, 'Settings', settings); + await Settings.saveSettings(this.fs, settings); + } + + getCoverImageUrl = (book: Book): string => BookSvc.getCoverImageUrl(this.coverCtx, book); + + getCoverImageBlobUrl = async (book: Book): Promise => + BookSvc.getCoverImageBlobUrl(this.coverCtx, book); + + async getCachedImageUrl(pathOrUrl: string): Promise { + return BookSvc.getCachedImageUrl(this.coverCtx, pathOrUrl); + } + + async generateCoverImageUrl(book: Book): Promise { + return BookSvc.generateCoverImageUrl(this.coverCtx, book); + } + + async updateCoverImage(book: Book, imageUrl?: string, imageFile?: string): Promise { + return BookSvc.updateCoverImage(this.coverCtx, book, imageUrl, imageFile); } async importFont(file?: string | File): Promise { - let fontPath: string; - let fontFile: File; - if (typeof file === 'string') { - const filePath = file; - const fileobj = await this.fs.openFile(filePath, 'None'); - fontPath = fileobj.name || getFilename(filePath); - await this.fs.copyFile(filePath, fontPath, 'Fonts'); - fontFile = await this.fs.openFile(fontPath, 'Fonts'); - } else if (file) { - fontPath = getFilename(file.name); - await this.fs.writeFile(fontPath, 'Fonts', file); - fontFile = file; - } else { - return null; - } - - return { - path: fontPath, - ...parseFontInfo(await fontFile.arrayBuffer(), fontPath), - }; + return FontSvc.importFont(this.fs, file); } async deleteFont(font: CustomFont): Promise { - await this.fs.removeFile(font.path, 'Fonts'); + return FontSvc.deleteFont(this.fs, font); } async importImage(file?: string | File): Promise { - let imagePath: string; - if (typeof file === 'string') { - const filePath = file; - const fileobj = await this.fs.openFile(filePath, 'None'); - imagePath = fileobj.name || getFilename(filePath); - await this.fs.copyFile(filePath, imagePath, 'Images'); - } else if (file) { - imagePath = getFilename(file.name); - await this.fs.writeFile(imagePath, 'Images', file); - } else { - return null; - } - - return { - name: imagePath.replace(/\.[^/.]+$/, ''), - path: imagePath, - }; + return ImageSvc.importImage(this.fs, file); } async deleteImage(texture: CustomTextureInfo): Promise { - await this.fs.removeFile(texture.path, 'Images'); + return ImageSvc.deleteImage(this.fs, texture); } async importBook( - // file might be: - // 1.1 absolute path for local file on Desktop - // 1.2 /private/var inbox file path on iOS - // 2. remote url - // 3. content provider uri - // 4. File object from browsers file: string | File, books: Book[], saveBook: boolean = true, @@ -361,185 +226,21 @@ export abstract class BaseAppService implements AppService { overwrite: boolean = false, transient: boolean = false, ): Promise { - try { - let loadedBook: BookDoc; - let format: BookFormat; - let filename: string; - let fileobj: File; - - if (transient && typeof file !== 'string') { - throw new Error('Transient import is only supported for file paths'); - } - - try { - if (typeof file === 'string') { - fileobj = await this.fs.openFile(file, 'None'); - filename = fileobj.name || getFilename(file); - } else { - fileobj = file; - filename = file.name; - } - if (/\.txt$/i.test(filename)) { - const txt2epub = new TxtToEpubConverter(); - ({ file: fileobj } = await txt2epub.convert({ file: fileobj })); - } - if (!fileobj || fileobj.size === 0) { - throw new Error('Invalid or empty book file'); - } - ({ book: loadedBook, format } = await new DocumentLoader(fileobj).open()); - if (!loadedBook) { - throw new Error('Unsupported or corrupted book file'); - } - const metadataTitle = formatTitle(loadedBook.metadata.title); - if (!metadataTitle || !metadataTitle.trim() || metadataTitle === filename) { - loadedBook.metadata.title = getBaseFilename(filename); - } - } catch (error) { - throw new Error(`Failed to open the book file: ${(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.createdAt = Date.now(); - existingBook.updatedAt = Date.now(); - } - - const primaryLanguage = getPrimaryLanguage(loadedBook.metadata.language); - const book: Book = { - hash, - format, - title: formatTitle(loadedBook.metadata.title), - sourceTitle: formatTitle(loadedBook.metadata.title), - primaryLanguage, - author: formatAuthors(loadedBook.metadata.author, primaryLanguage), - metadata: loadedBook.metadata, - createdAt: existingBook ? existingBook.createdAt : Date.now(), - uploadedAt: existingBook ? existingBook.uploadedAt : null, - deletedAt: transient ? Date.now() : null, - downloadedAt: Date.now(), - updatedAt: Date.now(), - }; - // update series info from metadata - if (book.metadata?.belongsTo?.series) { - const belongsTo = book.metadata.belongsTo.series; - const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo; - if (series) { - book.metadata.series = formatTitle(series.name); - book.metadata.seriesIndex = parseFloat(series.position || '0'); - } - } - // update book metadata when reimporting the same book - if (existingBook) { - existingBook.format = book.format; - existingBook.title = existingBook.title.trim() ? existingBook.title.trim() : book.title; - existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle; - existingBook.author = existingBook.author ?? book.author; - existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage; - existingBook.metadata = book.metadata; - existingBook.downloadedAt = Date.now(); - } - - if (!(await this.fs.exists(getDir(book), 'Books'))) { - await this.fs.createDir(getDir(book), 'Books'); - } - const bookFilename = getLocalBookFilename(book); - if (saveBook && !transient && (!(await this.fs.exists(bookFilename, 'Books')) || overwrite)) { - if (/\.txt$/i.test(filename)) { - await this.fs.writeFile(bookFilename, 'Books', fileobj); - } else if (typeof file === 'string' && isContentURI(file)) { - await this.fs.copyFile(file, bookFilename, 'Books'); - } else if (typeof file === 'string' && !isValidURL(file)) { - try { - // try to copy the file directly first in case of large files to avoid memory issues - // on desktop when reading recursively from selected directory the direct copy will fail - // due to permission issues, then fallback to read and write files - await this.fs.copyFile(file, bookFilename, 'Books'); - } catch { - await this.fs.writeFile(bookFilename, 'Books', await fileobj.arrayBuffer()); - } - } else { - await this.fs.writeFile(bookFilename, 'Books', fileobj); - } - } - if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) { - let cover = await loadedBook.getCover(); - if (cover?.type === 'image/svg+xml') { - try { - console.log('Converting SVG cover to PNG...'); - cover = await svg2png(cover); - } catch {} - } - 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); - } - - // update file links with url or path or content uri - if (typeof file === 'string') { - if (isValidURL(file)) { - book.url = file; - if (existingBook) existingBook.url = file; - } - if (transient) { - book.filePath = file; - if (existingBook) existingBook.filePath = file; - } - } - book.coverImageUrl = await this.generateCoverImageUrl(book); - const f = file as ClosableFile; - if (f && f.close) { - await f.close(); - } - - return existingBook || book; - } catch (error) { - console.error('Error importing book:', error); - throw error; - } + return BookSvc.importBook( + this.fs, + file, + books, + saveBook, + saveCover, + overwrite, + transient, + this.saveBookConfig.bind(this), + this.generateCoverImageUrl.bind(this), + ); } async deleteBook(book: Book, deleteAction: DeleteAction): Promise { - console.log('Deleting book with action:', deleteAction, book.title); - if (deleteAction === 'local' || deleteAction === 'both') { - const localDeleteFps = - deleteAction === 'local' - ? [getLocalBookFilename(book)] - : [getLocalBookFilename(book), getCoverFilename(book)]; - for (const fp of localDeleteFps) { - if (await this.fs.exists(fp, 'Books')) { - await this.fs.removeFile(fp, 'Books'); - } - } - if (deleteAction === 'local') { - book.downloadedAt = null; - } else { - book.deletedAt = Date.now(); - book.downloadedAt = null; - book.coverDownloadedAt = null; - } - } - if ((deleteAction === 'cloud' || deleteAction === 'both') && book.uploadedAt) { - const fps = [getRemoteBookFilename(book), getCoverFilename(book)]; - for (const fp of fps) { - 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.uploadedAt = null; - } + return CloudSvc.deleteBook(this.fs, book, deleteAction); } async uploadFileToCloud( @@ -550,105 +251,28 @@ export abstract class BaseAppService implements AppService { hash: string, temp: boolean = false, ) { - console.log('Uploading file:', lfp, 'to', cfp); - const file = await this.fs.openFile(lfp, base, cfp); - const localFullpath = await this.resolveFilePath(lfp, base); - const downloadUrl = await uploadFile(file, localFullpath, handleProgress, hash, temp); - const f = file as ClosableFile; - if (f && f.close) { - await f.close(); - } - return downloadUrl; + return CloudSvc.uploadFileToCloud( + this.fs, + this.resolveFilePath.bind(this), + lfp, + cfp, + base, + handleProgress, + hash, + temp, + ); } async uploadBook(book: Book, onProgress?: ProgressHandler): Promise { - let uploaded = false; - const completedFiles = { count: 0 }; - let toUploadFpCount = 0; - const coverExist = await this.fs.exists(getCoverFilename(book), 'Books'); - let bookFileExist = await this.fs.exists(getLocalBookFilename(book), 'Books'); - if (coverExist) { - toUploadFpCount++; - } - if (bookFileExist) { - toUploadFpCount++; - } - if (!bookFileExist && book.url) { - // download the book from the URL - const fileobj = await this.fs.openFile(book.url, 'None'); - await this.fs.writeFile(getLocalBookFilename(book), 'Books', await fileobj.arrayBuffer()); - bookFileExist = true; - } - - const handleProgress = createProgressHandler(toUploadFpCount, completedFiles, onProgress); - - if (coverExist) { - const lfp = getCoverFilename(book); - const cfp = `${CLOUD_BOOKS_SUBDIR}/${getCoverFilename(book)}`; - await this.uploadFileToCloud(lfp, cfp, 'Books', handleProgress, book.hash); - uploaded = true; - completedFiles.count++; - } - - if (bookFileExist) { - const lfp = getLocalBookFilename(book); - const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`; - await this.uploadFileToCloud(lfp, cfp, 'Books', handleProgress, book.hash); - uploaded = true; - completedFiles.count++; - } - - if (uploaded) { - book.deletedAt = null; - book.updatedAt = Date.now(); - book.uploadedAt = Date.now(); - book.downloadedAt = Date.now(); - book.coverDownloadedAt = Date.now(); - } else { - throw new Error('Book file not uploaded'); - } + return CloudSvc.uploadBook(this.fs, this.resolveFilePath.bind(this), book, onProgress); } async downloadCloudFile(lfp: string, cfp: string, onProgress: ProgressHandler) { - console.log('Downloading file:', cfp, 'to', lfp); - const dstPath = `${this.localBooksDir}/${lfp}`; - await downloadFile({ appService: this, cfp, dst: dstPath, onProgress }); + return CloudSvc.downloadCloudFile(this, this.localBooksDir, lfp, cfp, onProgress); } async downloadBookCovers(books: Book[]): Promise { - const booksLfps = new Map( - books.map((book) => { - const lfp = getCoverFilename(book); - return [lfp, book]; - }), - ); - const filePaths = books.map((book) => ({ - lfp: getCoverFilename(book), - cfp: `${CLOUD_BOOKS_SUBDIR}/${getCoverFilename(book)}`, - })); - const downloadUrls = await batchGetDownloadUrls(filePaths); - await Promise.all( - books.map(async (book) => { - if (!(await this.fs.exists(getDir(book), 'Books'))) { - await this.fs.createDir(getDir(book), 'Books'); - } - }), - ); - await Promise.all( - downloadUrls.map(async (file) => { - try { - const dst = `${this.localBooksDir}/${file.lfp}`; - if (!file.downloadUrl) return; - await downloadFile({ appService: this, dst, cfp: file.cfp, url: file.downloadUrl }); - const book = booksLfps.get(file.lfp); - if (book && !book.coverDownloadedAt) { - book.coverDownloadedAt = Date.now(); - } - } catch (error) { - console.log(`Failed to download cover file for book: '${file.lfp}'`, error); - } - }), - ); + return CloudSvc.downloadBookCovers(this, this.fs, this.localBooksDir, books); } async downloadBook( @@ -657,337 +281,56 @@ export abstract class BaseAppService implements AppService { redownload = false, onProgress?: ProgressHandler, ): Promise { - let bookDownloaded = false; - let bookCoverDownloaded = false; - const completedFiles = { count: 0 }; - let toDownloadFpCount = 0; - const needDownCover = !(await this.fs.exists(getCoverFilename(book), 'Books')) || redownload; - const needDownBook = - (!onlyCover && !(await this.fs.exists(getLocalBookFilename(book), 'Books'))) || redownload; - if (needDownCover) { - toDownloadFpCount++; - } - if (needDownBook) { - toDownloadFpCount++; - } - - const handleProgress = createProgressHandler(toDownloadFpCount, completedFiles, onProgress); - - if (!(await this.fs.exists(getDir(book), 'Books'))) { - await this.fs.createDir(getDir(book), 'Books'); - } - - try { - if (needDownCover) { - const lfp = getCoverFilename(book); - const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`; - await this.downloadCloudFile(lfp, cfp, handleProgress); - bookCoverDownloaded = true; - } - } catch (error) { - // don't throw error here since some books may not have cover images at all - console.log(`Failed to download cover file for book: '${book.title}'`, error); - } finally { - if (needDownCover) { - completedFiles.count++; - } - } - - if (needDownBook) { - const lfp = getLocalBookFilename(book); - const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`; - await this.downloadCloudFile(lfp, cfp, handleProgress); - const localFullpath = `${this.localBooksDir}/${lfp}`; - bookDownloaded = await this.fs.exists(localFullpath, 'None'); - completedFiles.count++; - } - // some books may not have cover image, so we need to check if the book is downloaded - if (bookDownloaded || (!onlyCover && !needDownBook)) { - book.downloadedAt = Date.now(); - } - if ((bookCoverDownloaded || !needDownCover) && !book.coverDownloadedAt) { - book.coverDownloadedAt = Date.now(); - } + return CloudSvc.downloadBook( + this, + this.fs, + this.localBooksDir, + book, + onlyCover, + redownload, + onProgress, + ); } async exportBook(book: Book): Promise { - const { file } = await this.loadBookContent(book); - const content = await file.arrayBuffer(); - const filename = `${makeSafeFilename(book.title)}.${book.format.toLowerCase()}`; - let filepath = await this.resolveFilePath(getLocalBookFilename(book), 'Books'); - const fileType = file.type || 'application/octet-stream'; - if (getFilename(filepath) !== filename) { - this.copyFile(filepath, filename, 'Temp'); - filepath = await this.resolveFilePath(filename, 'Temp'); - } - return await this.saveFile(filename, content, filepath, fileType); + return BookSvc.exportBook( + this.fs, + book, + this.resolveFilePath.bind(this), + this.copyFile.bind(this), + this.saveFile.bind(this), + ); } async isBookAvailable(book: Book): Promise { - const fp = getLocalBookFilename(book); - if (await this.fs.exists(fp, 'Books')) { - return true; - } - if (book.filePath) { - return await this.fs.exists(book.filePath, 'None'); - } - if (book.url) { - return isValidURL(book.url); - } - return false; + return BookSvc.isBookAvailable(this.fs, book); } async getBookFileSize(book: Book): Promise { - const fp = getLocalBookFilename(book); - if (await this.fs.exists(fp, 'Books')) { - const file = await this.fs.openFile(fp, 'Books'); - const size = file.size; - const f = file as ClosableFile; - if (f && f.close) { - await f.close(); - } - return size; - } - return null; + return BookSvc.getBookFileSize(this.fs, book); } async loadBookContent(book: Book): Promise { - let file: File; - const fp = getLocalBookFilename(book); - if (await this.fs.exists(fp, 'Books')) { - file = await this.fs.openFile(fp, 'Books'); - } else if (book.filePath) { - file = await this.fs.openFile(book.filePath, 'None'); - } else if (book.url) { - file = await this.fs.openFile(book.url, 'None'); - } else { - // 0.9.64 has a bug that book.title might be modified but the filename is not updated - const bookDir = getDir(book); - const files = await this.fs.readDir(getDir(book), 'Books'); - if (files.length > 0) { - const bookFile = files.find((f) => f.path.endsWith(`.${EXTS[book.format]}`)); - if (bookFile) { - file = await this.fs.openFile(`${bookDir}/${bookFile.path}`, 'Books'); - } else { - throw new BookFileNotFoundError(); - } - } else { - throw new BookFileNotFoundError(); - } - } - return { book, file }; + return BookSvc.loadBookContent(this.fs, book); } async loadBookConfig(book: Book, settings: SystemSettings): Promise { - const globalViewSettings = { - ...settings.globalViewSettings, - ...(FIXED_LAYOUT_FORMATS.has(book.format) ? DEFAULT_FIXED_LAYOUT_VIEW_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); - } + return BookSvc.loadBookConfig(this.fs, book, settings); } async fetchBookDetails(book: Book) { - const fp = getLocalBookFilename(book); - if (!(await this.fs.exists(fp, 'Books')) && book.uploadedAt) { - await this.downloadBook(book); - } - const { file } = await this.loadBookContent(book); - const bookDoc = (await new DocumentLoader(file).open()).book; - const f = file as ClosableFile; - if (f && f.close) { - await f.close(); - } - return bookDoc.metadata; + return BookSvc.fetchBookDetails(this.fs, book, this.downloadBook.bind(this)); } async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) { - let serializedConfig: string; - if (settings) { - const globalViewSettings = { - ...settings.globalViewSettings, - ...(FIXED_LAYOUT_FORMATS.has(book.format) ? DEFAULT_FIXED_LAYOUT_VIEW_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); + return BookSvc.saveBookConfig(this.fs, book, config, settings); } async loadLibraryBooks(): Promise { - console.log('Loading library books...'); - const libraryFilename = getLibraryFilename(); - - if (!(await this.fs.exists('', 'Books'))) { - await this.fs.createDir('', 'Books', true); - } - - const books = await this.safeLoadJSON(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; + return LibrarySvc.loadLibraryBooks(this.fs, this.generateCoverImageUrl.bind(this)); } async saveLibraryBooks(books: Book[]): Promise { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest); - await this.safeSaveJSON(getLibraryFilename(), 'Books', libraryBooks); - } - - private imageToArrayBuffer(imageUrl?: string, imageFile?: string): Promise { - return new Promise((resolve, reject) => { - if (!imageUrl && !imageFile) { - reject(new Error('No image URL or file provided')); - return; - } - if (this.appPlatform === 'web' && imageUrl && imageUrl.startsWith('blob:')) { - fetch(imageUrl) - .then((response) => response.arrayBuffer()) - .then((buffer) => resolve(buffer)) - .catch((error) => reject(error)); - } else if (this.appPlatform === 'tauri' && imageFile) { - this.fs - .openFile(imageFile, 'None') - .then((file) => file.arrayBuffer()) - .then((buffer) => resolve(buffer)) - .catch((error) => reject(error)); - } else if (this.appPlatform === 'tauri' && imageUrl) { - tauriFetch(imageUrl, { method: 'GET' }) - .then((response) => response.arrayBuffer()) - .then((buffer) => resolve(buffer)) - .catch((error) => reject(error)); - } else { - reject(new Error('Unsupported platform or missing image data')); - } - }); - } - - async updateCoverImage(book: Book, imageUrl?: string, imageFile?: string): Promise { - if (imageUrl === '_blank') { - await this.fs.removeFile(getCoverFilename(book), 'Books'); - } else if (imageUrl || imageFile) { - const arrayBuffer = await this.imageToArrayBuffer(imageUrl, imageFile); - await this.fs.writeFile(getCoverFilename(book), 'Books', arrayBuffer); - } - } - - private async loadJSONFile( - path: string, - base: BaseDir, - ): Promise<{ success: boolean; data?: unknown; error?: unknown }> { - try { - const txt = await this.fs.readFile(path, base, 'text'); - if (!txt || typeof txt !== 'string' || txt.trim().length === 0) { - return { success: false, error: 'File is empty or invalid' }; - } - try { - const data = JSON.parse(txt as string); - return { success: true, data }; - } catch (parseError) { - return { success: false, error: `JSON parse error: ${parseError}` }; - } - } catch (error) { - return { success: false, error }; - } - } - - /** - * Safely loads a JSON file with automatic backup fallback. - * If the main file is corrupted, attempts to load from backup. - * @param filename - The name of the file to load (without .bak extension) - * @param base - The base directory - * @param defaultValue - Default value to return if both files fail - */ - private async safeLoadJSON(filename: string, base: BaseDir, defaultValue: T): Promise { - const backupFilename = `${filename}.bak`; - - // Try loading main file - const mainResult = await this.loadJSONFile(filename, base); - if (mainResult.success) { - return mainResult.data as T; - } - - console.warn(`Failed to load ${filename}, attempting backup...`, mainResult.error); - - // Try loading backup file - const backupResult = await this.loadJSONFile(backupFilename, base); - if (backupResult.success) { - console.warn(`Loaded from backup: ${backupFilename}`); - // Restore the main file from backup - try { - const backupData = JSON.stringify(backupResult.data, null, 2); - await this.fs.writeFile(filename, base, backupData); - console.log(`Restored ${filename} from backup`); - } catch (error) { - console.error(`Failed to restore ${filename} from backup:`, error); - } - return backupResult.data as T; - } - - console.error(`Both ${filename} and ${backupFilename} failed to load`); - return defaultValue; - } - - /** - * Safely saves a JSON file with atomic write using backup strategy. - * Strategy: write to backup first, then to main file. - * This ensures at least one valid copy exists at all times. - * @param filename - The name of the file to save (without .bak extension) - * @param base - The base directory - * @param data - The data to save - */ - private async safeSaveJSON(filename: string, base: BaseDir, data: unknown): Promise { - const backupFilename = `${filename}.bak`; - const jsonData = JSON.stringify(data, null, 2); - - // Strategy: Always write to backup first, then to main file - // This ensures we always have at least one valid copy - try { - // Step 1: Write to backup file - await this.fs.writeFile(backupFilename, base, jsonData); - - // Step 2: Write to main file - await this.fs.writeFile(filename, base, jsonData); - } catch (error) { - console.error(`Failed to save ${filename}:`, error); - throw new Error(`Failed to save ${filename}: ${error}`); - } - } - - private async migrate20251124(): Promise { - console.log('Running migration for version 20251124 to rename the backup library file...'); - const oldBackupFilename = getLibraryBackupFilename(); - const newBackupFilename = `${getLibraryFilename()}.bak`; - if (await this.fs.exists(oldBackupFilename, 'Books')) { - try { - const content = await this.fs.readFile(oldBackupFilename, 'Books', 'text'); - await this.fs.writeFile(newBackupFilename, 'Books', content); - await this.fs.removeFile(oldBackupFilename, 'Books'); - console.log('Migration to rename backup library file completed successfully.'); - } catch (error) { - console.error('Error during migration to rename backup library file:', error); - } - } + return LibrarySvc.saveLibraryBooks(this.fs, books); } } diff --git a/apps/readest-app/src/services/bookService.ts b/apps/readest-app/src/services/bookService.ts new file mode 100644 index 00000000..020010d2 --- /dev/null +++ b/apps/readest-app/src/services/bookService.ts @@ -0,0 +1,455 @@ +import { SystemSettings } from '@/types/settings'; +import { FileSystem, AppPlatform, BaseDir } from '@/types/system'; +import { Book, BookConfig, BookContent, BookFormat, FIXED_LAYOUT_FORMATS } from '@/types/book'; +import { + getDir, + getLocalBookFilename, + getCoverFilename, + getConfigFilename, + INIT_BOOK_CONFIG, + formatTitle, + formatAuthors, + getPrimaryLanguage, + getMetadataHash, +} from '@/utils/book'; +import { partialMD5, md5 } from '@/utils/md5'; +import { getBaseFilename, getFilename } from '@/utils/path'; +import { BookDoc, DocumentLoader, EXTS } from '@/libs/document'; +import { DEFAULT_BOOK_SEARCH_CONFIG, DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS } from './constants'; +import { isContentURI, isValidURL, makeSafeFilename } from '@/utils/misc'; +import { deserializeConfig, serializeConfig } from '@/utils/serializer'; +import { ClosableFile } from '@/utils/file'; +import { TxtToEpubConverter } from '@/utils/txt'; +import { svg2png } from '@/utils/svg'; +import { BookFileNotFoundError } from './errors'; +import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; + +// --- Cover Image --- + +export interface CoverContext { + fs: FileSystem; + appPlatform: AppPlatform; + localBooksDir: string; +} + +export function getCoverImageUrl(ctx: CoverContext, book: Book): string { + return ctx.fs.getURL(`${ctx.localBooksDir}/${getCoverFilename(book)}`); +} + +export async function getCoverImageBlobUrl(ctx: CoverContext, book: Book): Promise { + return ctx.fs.getBlobURL(`${ctx.localBooksDir}/${getCoverFilename(book)}`, 'None'); +} + +export async function getCachedImageUrl(ctx: CoverContext, pathOrUrl: string): Promise { + const cachedKey = `img_${md5(pathOrUrl)}`; + const cachePrefix = await ctx.fs.getPrefix('Cache'); + const cachedPath = `${cachePrefix}/${cachedKey}`; + if (await ctx.fs.exists(cachedPath, 'None')) { + return await ctx.fs.getImageURL(cachedPath); + } else { + const file = await ctx.fs.openFile(pathOrUrl, 'None'); + await ctx.fs.writeFile(cachedKey, 'Cache', await file.arrayBuffer()); + return await ctx.fs.getImageURL(cachedPath); + } +} + +export async function generateCoverImageUrl(ctx: CoverContext, book: Book): Promise { + return ctx.appPlatform === 'web' + ? await getCoverImageBlobUrl(ctx, book) + : getCoverImageUrl(ctx, book); +} + +function imageToArrayBuffer( + ctx: CoverContext, + imageUrl?: string, + imageFile?: string, +): Promise { + return new Promise((resolve, reject) => { + if (!imageUrl && !imageFile) { + reject(new Error('No image URL or file provided')); + return; + } + if (ctx.appPlatform === 'web' && imageUrl && imageUrl.startsWith('blob:')) { + fetch(imageUrl) + .then((response) => response.arrayBuffer()) + .then(resolve) + .catch(reject); + } else if (ctx.appPlatform === 'tauri' && imageFile) { + ctx.fs + .openFile(imageFile, 'None') + .then((file) => file.arrayBuffer()) + .then(resolve) + .catch(reject); + } else if (ctx.appPlatform === 'tauri' && imageUrl) { + tauriFetch(imageUrl, { method: 'GET' }) + .then((response) => response.arrayBuffer()) + .then(resolve) + .catch(reject); + } else { + reject(new Error('Unsupported platform or missing image data')); + } + }); +} + +export async function updateCoverImage( + ctx: CoverContext, + book: Book, + imageUrl?: string, + imageFile?: string, +): Promise { + if (imageUrl === '_blank') { + await ctx.fs.removeFile(getCoverFilename(book), 'Books'); + } else if (imageUrl || imageFile) { + const arrayBuffer = await imageToArrayBuffer(ctx, imageUrl, imageFile); + await ctx.fs.writeFile(getCoverFilename(book), 'Books', arrayBuffer); + } +} + +// --- Book Import --- + +export async function importBook( + fs: FileSystem, + // file might be: + // 1.1 absolute path for local file on Desktop + // 1.2 /private/var inbox file path on iOS + // 2. remote url + // 3. content provider uri + // 4. File object from browsers + file: string | File, + books: Book[], + saveBook: boolean, + saveCover: boolean, + overwrite: boolean, + transient: boolean, + saveBookConfigFn: (book: Book, config: BookConfig) => Promise, + generateCoverImageUrlFn: (book: Book) => Promise, +): Promise { + try { + let loadedBook: BookDoc; + let format: BookFormat; + let filename: string; + let fileobj: File; + + if (transient && typeof file !== 'string') { + throw new Error('Transient import is only supported for file paths'); + } + + try { + if (typeof file === 'string') { + fileobj = await fs.openFile(file, 'None'); + filename = fileobj.name || getFilename(file); + } else { + fileobj = file; + filename = file.name; + } + if (/\.txt$/i.test(filename)) { + const txt2epub = new TxtToEpubConverter(); + ({ file: fileobj } = await txt2epub.convert({ file: fileobj })); + } + if (!fileobj || fileobj.size === 0) { + throw new Error('Invalid or empty book file'); + } + ({ book: loadedBook, format } = await new DocumentLoader(fileobj).open()); + if (!loadedBook) { + throw new Error('Unsupported or corrupted book file'); + } + const metadataTitle = formatTitle(loadedBook.metadata.title); + if (!metadataTitle || !metadataTitle.trim() || metadataTitle === filename) { + loadedBook.metadata.title = getBaseFilename(filename); + } + } catch (error) { + throw new Error(`Failed to open the book file: ${(error as Error).message || error}`); + } + + const hash = await partialMD5(fileobj); + const metaHash = getMetadataHash(loadedBook.metadata); + let existingBook = books.filter((b) => b.hash === hash)[0]; + let metaHashMatch = false; + let oldBookDir: string | undefined; + if (existingBook) { + if (!transient) { + existingBook.deletedAt = null; + } + existingBook.createdAt = Date.now(); + existingBook.updatedAt = Date.now(); + } + + // Check for metaHash match if no exact file match (different file, same book metadata) + if (!existingBook && !transient && metaHash) { + const metaHashBook = books.find((b) => b.metaHash === metaHash && !b.deletedAt); + if (metaHashBook) { + oldBookDir = getDir(metaHashBook); + existingBook = metaHashBook; + metaHashMatch = true; + existingBook.createdAt = Date.now(); + existingBook.updatedAt = Date.now(); + } + } + + const primaryLanguage = getPrimaryLanguage(loadedBook.metadata.language); + const book: Book = { + hash, + format, + metaHash, + title: formatTitle(loadedBook.metadata.title), + sourceTitle: formatTitle(loadedBook.metadata.title), + primaryLanguage, + author: formatAuthors(loadedBook.metadata.author, primaryLanguage), + metadata: loadedBook.metadata, + createdAt: existingBook ? existingBook.createdAt : Date.now(), + uploadedAt: existingBook ? existingBook.uploadedAt : null, + deletedAt: transient ? Date.now() : null, + downloadedAt: Date.now(), + updatedAt: Date.now(), + }; + // update series info from metadata + if (book.metadata?.belongsTo?.series) { + const belongsTo = book.metadata.belongsTo.series; + const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo; + if (series) { + book.metadata.series = formatTitle(series.name); + book.metadata.seriesIndex = parseFloat(series.position || '0'); + } + } + // update book metadata when reimporting the same book + if (existingBook && metaHashMatch) { + // MetaHash match (different file, same book): override metadata and hash + existingBook.hash = hash; + existingBook.format = book.format; + existingBook.metaHash = metaHash; + existingBook.title = book.title; + existingBook.sourceTitle = book.sourceTitle; + existingBook.author = book.author; + existingBook.primaryLanguage = book.primaryLanguage; + existingBook.metadata = book.metadata; + existingBook.uploadedAt = null; + existingBook.downloadedAt = Date.now(); + } else if (existingBook) { + // Same file hash: preserve user edits + existingBook.format = book.format; + existingBook.metaHash = metaHash; + existingBook.title = existingBook.title.trim() ? existingBook.title.trim() : book.title; + existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle; + existingBook.author = existingBook.author ?? book.author; + existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage; + existingBook.metadata = book.metadata; + existingBook.downloadedAt = Date.now(); + } + + if (!(await fs.exists(getDir(book), 'Books'))) { + await fs.createDir(getDir(book), 'Books'); + } + const bookFilename = getLocalBookFilename(book); + if (saveBook && !transient && (!(await fs.exists(bookFilename, 'Books')) || overwrite)) { + if (/\.txt$/i.test(filename)) { + await fs.writeFile(bookFilename, 'Books', fileobj); + } else if (typeof file === 'string' && isContentURI(file)) { + await fs.copyFile(file, bookFilename, 'Books'); + } else if (typeof file === 'string' && !isValidURL(file)) { + try { + // try to copy the file directly first in case of large files to avoid memory issues + // on desktop when reading recursively from selected directory the direct copy will fail + // due to permission issues, then fallback to read and write files + await fs.copyFile(file, bookFilename, 'Books'); + } catch { + await fs.writeFile(bookFilename, 'Books', await fileobj.arrayBuffer()); + } + } else { + await fs.writeFile(bookFilename, 'Books', fileobj); + } + } + if (saveCover && (!(await fs.exists(getCoverFilename(book), 'Books')) || overwrite)) { + let cover = await loadedBook.getCover(); + if (cover?.type === 'image/svg+xml') { + try { + console.log('Converting SVG cover to PNG...'); + cover = await svg2png(cover); + } catch {} + } + if (cover) { + await fs.writeFile(getCoverFilename(book), 'Books', await cover.arrayBuffer()); + } + } + // Never overwrite the config file only when it's not existed + if (!existingBook) { + await saveBookConfigFn(book, INIT_BOOK_CONFIG); + books.splice(0, 0, book); + } else if (metaHashMatch && oldBookDir && oldBookDir !== getDir(book)) { + // Migrate config from old directory to new directory, updating bookHash and metaHash + const oldConfigPath = `${oldBookDir}/config.json`; + if (await fs.exists(oldConfigPath, 'Books')) { + const configData = (await fs.readFile(oldConfigPath, 'Books', 'text')) as string; + const config: Partial = JSON.parse(configData); + config.bookHash = hash; + config.metaHash = metaHash; + await fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config)); + } else { + await saveBookConfigFn(book, INIT_BOOK_CONFIG); + } + // Clean up old directory + if (await fs.exists(oldBookDir, 'Books')) { + await fs.removeDir(oldBookDir, 'Books', true); + } + } + + // update file links with url or path or content uri + if (typeof file === 'string') { + if (isValidURL(file)) { + book.url = file; + if (existingBook) existingBook.url = file; + } + if (transient) { + book.filePath = file; + if (existingBook) existingBook.filePath = file; + } + } + book.coverImageUrl = await generateCoverImageUrlFn(book); + const f = file as ClosableFile; + if (f && f.close) { + await f.close(); + } + + return existingBook || book; + } catch (error) { + console.error('Error importing book:', error); + throw error; + } +} + +// --- Book Content & Config --- + +export async function isBookAvailable(fs: FileSystem, book: Book): Promise { + const fp = getLocalBookFilename(book); + if (await fs.exists(fp, 'Books')) { + return true; + } + if (book.filePath) { + return await fs.exists(book.filePath, 'None'); + } + if (book.url) { + return isValidURL(book.url); + } + return false; +} + +export async function getBookFileSize(fs: FileSystem, book: Book): Promise { + const fp = getLocalBookFilename(book); + if (await fs.exists(fp, 'Books')) { + const file = await fs.openFile(fp, 'Books'); + const size = file.size; + const f = file as ClosableFile; + if (f && f.close) { + await f.close(); + } + return size; + } + return null; +} + +export async function loadBookContent(fs: FileSystem, book: Book): Promise { + let file: File; + const fp = getLocalBookFilename(book); + if (await fs.exists(fp, 'Books')) { + file = await fs.openFile(fp, 'Books'); + } else if (book.filePath) { + file = await fs.openFile(book.filePath, 'None'); + } else if (book.url) { + file = await fs.openFile(book.url, 'None'); + } else { + // 0.9.64 has a bug that book.title might be modified but the filename is not updated + const bookDir = getDir(book); + const files = await fs.readDir(getDir(book), 'Books'); + if (files.length > 0) { + const bookFile = files.find((f) => f.path.endsWith(`.${EXTS[book.format]}`)); + if (bookFile) { + file = await fs.openFile(`${bookDir}/${bookFile.path}`, 'Books'); + } else { + throw new BookFileNotFoundError(); + } + } else { + throw new BookFileNotFoundError(); + } + } + return { book, file }; +} + +export async function loadBookConfig( + fs: FileSystem, + book: Book, + settings: SystemSettings, +): Promise { + const globalViewSettings = { + ...settings.globalViewSettings, + ...(FIXED_LAYOUT_FORMATS.has(book.format) ? DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS : {}), + }; + try { + let str = '{}'; + if (await fs.exists(getConfigFilename(book), 'Books')) { + str = (await fs.readFile(getConfigFilename(book), 'Books', 'text')) as string; + } + return deserializeConfig(str, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); + } catch { + return deserializeConfig('{}', globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); + } +} + +export async function saveBookConfig( + fs: FileSystem, + book: Book, + config: BookConfig, + settings?: SystemSettings, +): Promise { + let serializedConfig: string; + if (settings) { + const globalViewSettings = { + ...settings.globalViewSettings, + ...(FIXED_LAYOUT_FORMATS.has(book.format) ? DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS : {}), + }; + serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); + } else { + serializedConfig = JSON.stringify(config); + } + await fs.writeFile(getConfigFilename(book), 'Books', serializedConfig); +} + +export async function fetchBookDetails( + fs: FileSystem, + book: Book, + downloadBookFn: (book: Book) => Promise, +): Promise { + const fp = getLocalBookFilename(book); + if (!(await fs.exists(fp, 'Books')) && book.uploadedAt) { + await downloadBookFn(book); + } + const { file } = await loadBookContent(fs, book); + const bookDoc = (await new DocumentLoader(file).open()).book; + const f = file as ClosableFile; + if (f && f.close) { + await f.close(); + } + return bookDoc.metadata; +} + +export async function exportBook( + fs: FileSystem, + book: Book, + resolveFilePath: (path: string, base: BaseDir) => Promise, + copyFile: (srcPath: string, dstPath: string, base: BaseDir) => Promise, + saveFile: ( + filename: string, + content: ArrayBuffer, + filepath: string, + mimeType?: string, + ) => Promise, +): Promise { + const { file } = await loadBookContent(fs, book); + const content = await file.arrayBuffer(); + const filename = `${makeSafeFilename(book.title)}.${book.format.toLowerCase()}`; + let filepath = await resolveFilePath(getLocalBookFilename(book), 'Books'); + const fileType = file.type || 'application/octet-stream'; + if (getFilename(filepath) !== filename) { + await copyFile(filepath, filename, 'Temp'); + filepath = await resolveFilePath(filename, 'Temp'); + } + return await saveFile(filename, content, filepath, fileType); +} diff --git a/apps/readest-app/src/services/cloudService.ts b/apps/readest-app/src/services/cloudService.ts new file mode 100644 index 00000000..350a490d --- /dev/null +++ b/apps/readest-app/src/services/cloudService.ts @@ -0,0 +1,245 @@ +import { AppService, FileSystem, BaseDir, DeleteAction } from '@/types/system'; +import { Book } from '@/types/book'; +import { + getDir, + getLocalBookFilename, + getRemoteBookFilename, + getCoverFilename, +} from '@/utils/book'; +import { + downloadFile, + uploadFile, + deleteFile as deleteCloudFile, + createProgressHandler, + batchGetDownloadUrls, +} from '@/libs/storage'; +import { ClosableFile } from '@/utils/file'; +import { ProgressHandler } from '@/utils/transfer'; +import { CLOUD_BOOKS_SUBDIR } from './constants'; + +export async function deleteBook( + fs: FileSystem, + book: Book, + deleteAction: DeleteAction, +): Promise { + console.log('Deleting book with action:', deleteAction, book.title); + if (deleteAction === 'local' || deleteAction === 'both') { + const localDeleteFps = + deleteAction === 'local' + ? [getLocalBookFilename(book)] + : [getLocalBookFilename(book), getCoverFilename(book)]; + for (const fp of localDeleteFps) { + if (await fs.exists(fp, 'Books')) { + await fs.removeFile(fp, 'Books'); + } + } + if (deleteAction === 'local') { + book.downloadedAt = null; + } else { + book.deletedAt = Date.now(); + book.downloadedAt = null; + book.coverDownloadedAt = null; + } + } + if ((deleteAction === 'cloud' || deleteAction === 'both') && book.uploadedAt) { + const fps = [getRemoteBookFilename(book), getCoverFilename(book)]; + for (const fp of fps) { + console.log('Deleting uploaded file:', fp); + const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`; + try { + deleteCloudFile(cfp); + } catch (error) { + console.log('Failed to delete uploaded file:', error); + } + } + book.uploadedAt = null; + } +} + +export async function uploadFileToCloud( + fs: FileSystem, + resolveFilePath: (path: string, base: BaseDir) => Promise, + lfp: string, + cfp: string, + base: BaseDir, + handleProgress: ProgressHandler, + hash: string, + temp: boolean = false, +): Promise { + console.log('Uploading file:', lfp, 'to', cfp); + const file = await fs.openFile(lfp, base, cfp); + const localFullpath = await resolveFilePath(lfp, base); + const downloadUrl = await uploadFile(file, localFullpath, handleProgress, hash, temp); + const f = file as ClosableFile; + if (f && f.close) { + await f.close(); + } + return downloadUrl; +} + +export async function uploadBook( + fs: FileSystem, + resolveFilePath: (path: string, base: BaseDir) => Promise, + book: Book, + onProgress?: ProgressHandler, +): Promise { + let uploaded = false; + const completedFiles = { count: 0 }; + let toUploadFpCount = 0; + const coverExist = await fs.exists(getCoverFilename(book), 'Books'); + let bookFileExist = await fs.exists(getLocalBookFilename(book), 'Books'); + if (coverExist) { + toUploadFpCount++; + } + if (bookFileExist) { + toUploadFpCount++; + } + if (!bookFileExist && book.url) { + const fileobj = await fs.openFile(book.url, 'None'); + await fs.writeFile(getLocalBookFilename(book), 'Books', await fileobj.arrayBuffer()); + bookFileExist = true; + } + + const handleProgress = createProgressHandler(toUploadFpCount, completedFiles, onProgress); + + if (coverExist) { + const lfp = getCoverFilename(book); + const cfp = `${CLOUD_BOOKS_SUBDIR}/${getCoverFilename(book)}`; + await uploadFileToCloud(fs, resolveFilePath, lfp, cfp, 'Books', handleProgress, book.hash); + uploaded = true; + completedFiles.count++; + } + + if (bookFileExist) { + const lfp = getLocalBookFilename(book); + const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`; + await uploadFileToCloud(fs, resolveFilePath, lfp, cfp, 'Books', handleProgress, book.hash); + uploaded = true; + completedFiles.count++; + } + + if (uploaded) { + book.deletedAt = null; + book.updatedAt = Date.now(); + book.uploadedAt = Date.now(); + book.downloadedAt = Date.now(); + book.coverDownloadedAt = Date.now(); + } else { + throw new Error('Book file not uploaded'); + } +} + +export async function downloadCloudFile( + appService: AppService, + localBooksDir: string, + lfp: string, + cfp: string, + onProgress: ProgressHandler, +): Promise { + console.log('Downloading file:', cfp, 'to', lfp); + const dstPath = `${localBooksDir}/${lfp}`; + await downloadFile({ appService, cfp, dst: dstPath, onProgress }); +} + +export async function downloadBookCovers( + appService: AppService, + fs: FileSystem, + localBooksDir: string, + books: Book[], +): Promise { + const booksLfps = new Map( + books.map((book) => { + const lfp = getCoverFilename(book); + return [lfp, book]; + }), + ); + const filePaths = books.map((book) => ({ + lfp: getCoverFilename(book), + cfp: `${CLOUD_BOOKS_SUBDIR}/${getCoverFilename(book)}`, + })); + const downloadUrls = await batchGetDownloadUrls(filePaths); + await Promise.all( + books.map(async (book) => { + if (!(await fs.exists(getDir(book), 'Books'))) { + await fs.createDir(getDir(book), 'Books'); + } + }), + ); + await Promise.all( + downloadUrls.map(async (file) => { + try { + const dst = `${localBooksDir}/${file.lfp}`; + if (!file.downloadUrl) return; + await downloadFile({ appService, dst, cfp: file.cfp, url: file.downloadUrl }); + const book = booksLfps.get(file.lfp); + if (book && !book.coverDownloadedAt) { + book.coverDownloadedAt = Date.now(); + } + } catch (error) { + console.log(`Failed to download cover file for book: '${file.lfp}'`, error); + } + }), + ); +} + +export async function downloadBook( + appService: AppService, + fs: FileSystem, + localBooksDir: string, + book: Book, + onlyCover: boolean = false, + redownload: boolean = false, + onProgress?: ProgressHandler, +): Promise { + let bookDownloaded = false; + let bookCoverDownloaded = false; + const completedFiles = { count: 0 }; + let toDownloadFpCount = 0; + const needDownCover = !(await fs.exists(getCoverFilename(book), 'Books')) || redownload; + const needDownBook = + (!onlyCover && !(await fs.exists(getLocalBookFilename(book), 'Books'))) || redownload; + if (needDownCover) { + toDownloadFpCount++; + } + if (needDownBook) { + toDownloadFpCount++; + } + + const handleProgress = createProgressHandler(toDownloadFpCount, completedFiles, onProgress); + + if (!(await fs.exists(getDir(book), 'Books'))) { + await fs.createDir(getDir(book), 'Books'); + } + + try { + if (needDownCover) { + const lfp = getCoverFilename(book); + const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`; + await downloadCloudFile(appService, localBooksDir, lfp, cfp, handleProgress); + bookCoverDownloaded = true; + } + } catch (error) { + // don't throw error here since some books may not have cover images at all + console.log(`Failed to download cover file for book: '${book.title}'`, error); + } finally { + if (needDownCover) { + completedFiles.count++; + } + } + + if (needDownBook) { + const lfp = getLocalBookFilename(book); + const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`; + await downloadCloudFile(appService, localBooksDir, lfp, cfp, handleProgress); + const localFullpath = `${localBooksDir}/${lfp}`; + bookDownloaded = await fs.exists(localFullpath, 'None'); + completedFiles.count++; + } + // some books may not have cover image, so we need to check if the book is downloaded + if (bookDownloaded || (!onlyCover && !needDownBook)) { + book.downloadedAt = Date.now(); + } + if ((bookCoverDownloaded || !needDownCover) && !book.coverDownloadedAt) { + book.coverDownloadedAt = Date.now(); + } +} diff --git a/apps/readest-app/src/services/fontService.ts b/apps/readest-app/src/services/fontService.ts new file mode 100644 index 00000000..95389128 --- /dev/null +++ b/apps/readest-app/src/services/fontService.ts @@ -0,0 +1,34 @@ +import { FileSystem } from '@/types/system'; +import { getFilename } from '@/utils/path'; +import { CustomFont, CustomFontInfo } from '@/styles/fonts'; +import { parseFontInfo } from '@/utils/font'; + +export async function importFont( + fs: FileSystem, + file?: string | File, +): Promise { + let fontPath: string; + let fontFile: File; + if (typeof file === 'string') { + const filePath = file; + const fileobj = await fs.openFile(filePath, 'None'); + fontPath = fileobj.name || getFilename(filePath); + await fs.copyFile(filePath, fontPath, 'Fonts'); + fontFile = await fs.openFile(fontPath, 'Fonts'); + } else if (file) { + fontPath = getFilename(file.name); + await fs.writeFile(fontPath, 'Fonts', file); + fontFile = file; + } else { + return null; + } + + return { + path: fontPath, + ...parseFontInfo(await fontFile.arrayBuffer(), fontPath), + }; +} + +export async function deleteFont(fs: FileSystem, font: CustomFont): Promise { + await fs.removeFile(font.path, 'Fonts'); +} diff --git a/apps/readest-app/src/services/imageService.ts b/apps/readest-app/src/services/imageService.ts new file mode 100644 index 00000000..998164c1 --- /dev/null +++ b/apps/readest-app/src/services/imageService.ts @@ -0,0 +1,30 @@ +import { FileSystem } from '@/types/system'; +import { getFilename } from '@/utils/path'; +import { CustomTextureInfo } from '@/styles/textures'; + +export async function importImage( + fs: FileSystem, + file?: string | File, +): Promise { + let imagePath: string; + if (typeof file === 'string') { + const filePath = file; + const fileobj = await fs.openFile(filePath, 'None'); + imagePath = fileobj.name || getFilename(filePath); + await fs.copyFile(filePath, imagePath, 'Images'); + } else if (file) { + imagePath = getFilename(file.name); + await fs.writeFile(imagePath, 'Images', file); + } else { + return null; + } + + return { + name: imagePath.replace(/\.[^/.]+$/, ''), + path: imagePath, + }; +} + +export async function deleteImage(fs: FileSystem, texture: CustomTextureInfo): Promise { + await fs.removeFile(texture.path, 'Images'); +} diff --git a/apps/readest-app/src/services/libraryService.ts b/apps/readest-app/src/services/libraryService.ts new file mode 100644 index 00000000..0fbbad4c --- /dev/null +++ b/apps/readest-app/src/services/libraryService.ts @@ -0,0 +1,33 @@ +import { FileSystem } from '@/types/system'; +import { Book } from '@/types/book'; +import { getLibraryFilename } from '@/utils/book'; +import { safeLoadJSON, safeSaveJSON } from './persistence'; + +export async function loadLibraryBooks( + fs: FileSystem, + generateCoverImageUrl: (book: Book) => Promise, +): Promise { + const libraryFilename = getLibraryFilename(); + + if (!(await fs.exists('', 'Books'))) { + await fs.createDir('', 'Books', true); + } + + const books = await safeLoadJSON(fs, libraryFilename, 'Books', []); + + await Promise.all( + books.map(async (book) => { + book.coverImageUrl = await generateCoverImageUrl(book); + book.updatedAt ??= book.lastUpdated || Date.now(); + return book; + }), + ); + + return books; +} + +export async function saveLibraryBooks(fs: FileSystem, books: Book[]): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest); + await safeSaveJSON(fs, getLibraryFilename(), 'Books', libraryBooks); +} diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 7a55a1c1..fa2eba52 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -239,9 +239,13 @@ export const nativeFileSystem: FileSystem = { // NOTE: RemoteFile currently performs about 2× faster than NativeFile // due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190). // Once the bug is resolved, we should switch back to using NativeFile. - const prefix = await this.getPrefix(base); - const absolutePath = prefix ? await join(prefix, path) : path; - return await new RemoteFile(this.getURL(absolutePath), fname).open(); + try { + const prefix = await this.getPrefix(base); + const absolutePath = prefix ? await join(prefix, path) : path; + return await new RemoteFile(this.getURL(absolutePath), fname).open(); + } catch { + return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); + } } } }, @@ -439,6 +443,14 @@ export class NativeAppService extends BaseAppService { override isOnlineCatalogsAccessible = true; private execDir?: string = undefined; + private customRootDir?: string = undefined; + + constructor(customRootDir?: string) { + super(); + if (customRootDir) { + this.customRootDir = customRootDir; + } + } override async init() { const execDir = await invoke('get_executable_dir'); @@ -455,9 +467,9 @@ export class NativeAppService extends BaseAppService { }); } const settings = await this.loadSettings(); - if (settings.customRootDir) { + if (this.customRootDir || settings.customRootDir) { this.fs.resolvePath = getPathResolver({ - customRootDir: settings.customRootDir, + customRootDir: this.customRootDir || settings.customRootDir, isPortable: this.isPortableApp, execDir, }); diff --git a/apps/readest-app/src/services/nodeAppService.ts b/apps/readest-app/src/services/nodeAppService.ts new file mode 100644 index 00000000..75f6ec5e --- /dev/null +++ b/apps/readest-app/src/services/nodeAppService.ts @@ -0,0 +1,401 @@ +import * as fs from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import * as nodePath from 'node:path'; +import * as os from 'node:os'; +import { pathToFileURL } from 'node:url'; + +import { FileSystem, BaseDir, OsPlatform, ResolvedPath, FileItem, FileInfo } from '@/types/system'; +import { DatabaseOpts, DatabaseService } from '@/types/database'; +import { SchemaType } from '@/services/database/migrate'; +import { BaseAppService } from './appService'; +import { + DATA_SUBDIR, + LOCAL_BOOKS_SUBDIR, + LOCAL_FONTS_SUBDIR, + LOCAL_IMAGES_SUBDIR, +} from './constants'; + +const APP_NAME = 'Readest'; + +// System directory getters matching Tauri's appDataDir, appConfigDir, etc. +function getAppDataDir(): string { + switch (process.platform) { + case 'darwin': + return nodePath.join(os.homedir(), 'Library', 'Application Support', APP_NAME); + case 'win32': + return nodePath.join( + process.env['APPDATA'] || nodePath.join(os.homedir(), 'AppData', 'Roaming'), + APP_NAME, + ); + default: + return nodePath.join( + process.env['XDG_DATA_HOME'] || nodePath.join(os.homedir(), '.local', 'share'), + APP_NAME, + ); + } +} + +function getAppConfigDir(): string { + switch (process.platform) { + case 'darwin': + return nodePath.join(os.homedir(), 'Library', 'Application Support', APP_NAME); + case 'win32': + return nodePath.join( + process.env['APPDATA'] || nodePath.join(os.homedir(), 'AppData', 'Roaming'), + APP_NAME, + ); + default: + return nodePath.join( + process.env['XDG_CONFIG_HOME'] || nodePath.join(os.homedir(), '.config'), + APP_NAME, + ); + } +} + +function getAppCacheDir(): string { + switch (process.platform) { + case 'darwin': + return nodePath.join(os.homedir(), 'Library', 'Caches', APP_NAME); + case 'win32': + return nodePath.join( + process.env['LOCALAPPDATA'] || nodePath.join(os.homedir(), 'AppData', 'Local'), + APP_NAME, + 'Cache', + ); + default: + return nodePath.join( + process.env['XDG_CACHE_HOME'] || nodePath.join(os.homedir(), '.cache'), + APP_NAME, + ); + } +} + +function getAppLogDir(): string { + switch (process.platform) { + case 'darwin': + return nodePath.join(os.homedir(), 'Library', 'Logs', APP_NAME); + case 'win32': + return nodePath.join( + process.env['LOCALAPPDATA'] || nodePath.join(os.homedir(), 'AppData', 'Local'), + APP_NAME, + 'Logs', + ); + default: + return nodePath.join( + process.env['XDG_STATE_HOME'] || nodePath.join(os.homedir(), '.local', 'state'), + APP_NAME, + ); + } +} + +function getTempDir(): string { + return nodePath.join(os.tmpdir(), APP_NAME); +} + +// Path resolver matching nativeAppService's getPathResolver pattern. +// When customRootDir is set, Settings/Data/Books/Fonts/Images all resolve under it. +// Otherwise they use standard system directories. +const getPathResolver = ({ customRootDir }: { customRootDir?: string } = {}) => { + const isCustomBaseDir = Boolean(customRootDir); + const getCustomBasePrefix = isCustomBaseDir + ? (base: BaseDir) => { + const dataDirs: BaseDir[] = ['Settings', 'Data', 'Books', 'Fonts', 'Images']; + const leafDir = dataDirs.includes(base) ? '' : base; + return leafDir ? `${customRootDir}/${leafDir}` : customRootDir!; + } + : undefined; + + return (fp: string, base: BaseDir): ResolvedPath => { + const custom = getCustomBasePrefix?.(base); + switch (base) { + case 'Settings': + return { + baseDir: 0, + basePrefix: async () => custom ?? getAppConfigDir(), + fp: custom ? `${custom}${fp ? `/${fp}` : ''}` : fp, + base, + }; + case 'Cache': + return { + baseDir: 0, + basePrefix: async () => getAppCacheDir(), + fp, + base, + }; + case 'Log': + return { + baseDir: 0, + basePrefix: async () => custom ?? getAppLogDir(), + fp: custom ? `${custom}${fp ? `/${fp}` : ''}` : fp, + base, + }; + case 'Data': + return { + baseDir: 0, + basePrefix: async () => custom ?? getAppDataDir(), + fp: custom + ? `${custom}/${DATA_SUBDIR}${fp ? `/${fp}` : ''}` + : `${DATA_SUBDIR}${fp ? `/${fp}` : ''}`, + base, + }; + case 'Books': + return { + baseDir: 0, + basePrefix: async () => custom ?? getAppDataDir(), + fp: custom + ? `${custom}/${LOCAL_BOOKS_SUBDIR}${fp ? `/${fp}` : ''}` + : `${LOCAL_BOOKS_SUBDIR}${fp ? `/${fp}` : ''}`, + base, + }; + case 'Fonts': + return { + baseDir: 0, + basePrefix: async () => custom ?? getAppDataDir(), + fp: custom + ? `${custom}/${LOCAL_FONTS_SUBDIR}${fp ? `/${fp}` : ''}` + : `${LOCAL_FONTS_SUBDIR}${fp ? `/${fp}` : ''}`, + base, + }; + case 'Images': + return { + baseDir: 0, + basePrefix: async () => custom ?? getAppDataDir(), + fp: custom + ? `${custom}/${LOCAL_IMAGES_SUBDIR}${fp ? `/${fp}` : ''}` + : `${LOCAL_IMAGES_SUBDIR}${fp ? `/${fp}` : ''}`, + base, + }; + case 'None': + return { + baseDir: 0, + basePrefix: async () => '', + fp, + base, + }; + case 'Temp': + default: + return { + baseDir: 0, + basePrefix: async () => getTempDir(), + fp, + base, + }; + } + }; +}; + +// Resolve an fp from resolvePath to an absolute path. +// When customRootDir is set, fp is already absolute; otherwise join with the base prefix. +async function toAbsolute(resolved: ResolvedPath): Promise { + if (nodePath.isAbsolute(resolved.fp)) return resolved.fp; + const prefix = (await resolved.basePrefix()).replace(/\/+$/, ''); + return resolved.fp ? nodePath.join(prefix, resolved.fp) : prefix; +} + +export const nodeFileSystem: FileSystem = { + resolvePath: getPathResolver(), + + async getPrefix(base: BaseDir) { + return toAbsolute(this.resolvePath('', base)); + }, + + getURL(filePath: string) { + return pathToFileURL(filePath).href; + }, + + async getBlobURL(filePath: string, base: BaseDir) { + const content = await this.readFile(filePath, base, 'binary'); + return `data:application/octet-stream;base64,${Buffer.from(content as ArrayBuffer).toString('base64')}`; + }, + + async getImageURL(filePath: string) { + return this.getURL(filePath); + }, + + async openFile(filePath: string, base: BaseDir, name?: string): Promise { + const fullPath = await toAbsolute(this.resolvePath(filePath, base)); + const buffer = await fsp.readFile(fullPath); + const fileName = name || nodePath.basename(fullPath); + return new File([buffer], fileName); + }, + + async copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise { + const fullDst = await toAbsolute(this.resolvePath(dstPath, base)); + await fsp.mkdir(nodePath.dirname(fullDst), { recursive: true }); + await fsp.copyFile(srcPath, fullDst); + }, + + async readFile( + filePath: string, + base: BaseDir, + mode: 'text' | 'binary', + ): Promise { + const fullPath = await toAbsolute(this.resolvePath(filePath, base)); + if (mode === 'text') { + return await fsp.readFile(fullPath, 'utf-8'); + } + const buffer = await fsp.readFile(fullPath); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + }, + + async writeFile( + filePath: string, + base: BaseDir, + content: string | ArrayBuffer | File, + ): Promise { + const fullPath = await toAbsolute(this.resolvePath(filePath, base)); + await fsp.mkdir(nodePath.dirname(fullPath), { recursive: true }); + if (typeof content === 'string') { + await fsp.writeFile(fullPath, content, 'utf-8'); + } else if (content instanceof File) { + const buffer = Buffer.from(await content.arrayBuffer()); + await fsp.writeFile(fullPath, buffer); + } else { + await fsp.writeFile(fullPath, Buffer.from(content)); + } + }, + + async removeFile(filePath: string, base: BaseDir): Promise { + const fullPath = await toAbsolute(this.resolvePath(filePath, base)); + await fsp.unlink(fullPath); + }, + + async createDir(dirPath: string, base: BaseDir, recursive = false): Promise { + const fullPath = await toAbsolute(this.resolvePath(dirPath, base)); + await fsp.mkdir(fullPath, { recursive }); + }, + + async removeDir(dirPath: string, base: BaseDir, recursive = false): Promise { + const fullPath = await toAbsolute(this.resolvePath(dirPath, base)); + await fsp.rm(fullPath, { recursive, force: recursive }); + }, + + async readDir(dirPath: string, base: BaseDir): Promise { + const fullPath = await toAbsolute(this.resolvePath(dirPath, base)); + const items: FileItem[] = []; + + const walk = async (dir: string, relative: string) => { + let entries: fs.Dirent[]; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const entryRelative = relative ? nodePath.join(relative, entry.name) : entry.name; + const entryFull = nodePath.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(entryFull, entryRelative); + } else if (entry.isFile()) { + const stat = await fsp.stat(entryFull).catch(() => null); + items.push({ path: entryRelative, size: stat?.size ?? 0 }); + } + } + }; + + await walk(fullPath, ''); + return items; + }, + + async exists(filePath: string, base: BaseDir): Promise { + const fullPath = await toAbsolute(this.resolvePath(filePath, base)); + try { + await fsp.access(fullPath); + return true; + } catch { + return false; + } + }, + + async stats(filePath: string, base: BaseDir): Promise { + const fullPath = await toAbsolute(this.resolvePath(filePath, base)); + const stat = await fsp.stat(fullPath); + return { + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + size: stat.size, + mtime: stat.mtime, + atime: stat.atime, + birthtime: stat.birthtime, + }; + }, +}; + +export class NodeAppService extends BaseAppService { + protected fs = nodeFileSystem; + override appPlatform = 'node' as const; + override osPlatform: OsPlatform = + process.platform === 'darwin' + ? 'macos' + : process.platform === 'win32' + ? 'windows' + : process.platform === 'linux' + ? 'linux' + : 'unknown'; + + constructor(customRootDir?: string) { + super(); + if (customRootDir) { + this.fs.resolvePath = getPathResolver({ customRootDir: nodePath.resolve(customRootDir) }); + } + } + + protected resolvePath(fp: string, base: BaseDir): ResolvedPath { + return this.fs.resolvePath(fp, base); + } + + async init(): Promise { + await this.prepareBooksDir(); + } + + async setCustomRootDir(customRootDir: string): Promise { + this.fs.resolvePath = getPathResolver({ customRootDir: nodePath.resolve(customRootDir) }); + await this.prepareBooksDir(); + } + + async selectDirectory(): Promise { + throw new Error('selectDirectory is not supported in Node.js environment'); + } + + async selectFiles(): Promise { + throw new Error('selectFiles is not supported in Node.js environment'); + } + + async saveFile( + _filename: string, + content: string | ArrayBuffer, + filepath: string, + ): Promise { + try { + await fsp.mkdir(nodePath.dirname(filepath), { recursive: true }); + if (typeof content === 'string') { + await fsp.writeFile(filepath, content, 'utf-8'); + } else { + await fsp.writeFile(filepath, Buffer.from(content)); + } + return true; + } catch (error) { + console.error('Failed to save file:', error); + return false; + } + } + + async ask(): Promise { + return false; + } + + async openDatabase( + schema: SchemaType, + path: string, + base: BaseDir, + opts?: DatabaseOpts, + ): Promise { + const fullPath = await this.resolveFilePath(path, base); + const { NodeDatabaseService } = await import('./database/nodeDatabaseService'); + const db = await NodeDatabaseService.open(fullPath, opts); + const { migrate } = await import('./database/migrate'); + const { getMigrations } = await import('./database/migrations'); + await migrate(db, getMigrations(schema)); + return db; + } +} diff --git a/apps/readest-app/src/services/persistence.ts b/apps/readest-app/src/services/persistence.ts new file mode 100644 index 00000000..4fce79ef --- /dev/null +++ b/apps/readest-app/src/services/persistence.ts @@ -0,0 +1,76 @@ +import { FileSystem, BaseDir } from '@/types/system'; + +async function loadJSONFile( + fs: FileSystem, + path: string, + base: BaseDir, +): Promise<{ success: boolean; data?: unknown; error?: unknown }> { + try { + const txt = await fs.readFile(path, base, 'text'); + if (!txt || typeof txt !== 'string' || txt.trim().length === 0) { + return { success: false, error: 'File is empty or invalid' }; + } + try { + const data = JSON.parse(txt as string); + return { success: true, data }; + } catch (parseError) { + return { success: false, error: `JSON parse error: ${parseError}` }; + } + } catch (error) { + return { success: false, error }; + } +} + +/** + * Safely loads a JSON file with automatic backup fallback. + * If the main file is corrupted, attempts to load from backup. + */ +export async function safeLoadJSON( + fs: FileSystem, + filename: string, + base: BaseDir, + defaultValue: T, +): Promise { + const backupFilename = `${filename}.bak`; + + const mainResult = await loadJSONFile(fs, filename, base); + if (mainResult.success) { + return mainResult.data as T; + } + + const backupResult = await loadJSONFile(fs, backupFilename, base); + if (backupResult.success) { + try { + const backupData = JSON.stringify(backupResult.data, null, 2); + await fs.writeFile(filename, base, backupData); + } catch (error) { + console.info(`Failed to restore ${filename} from backup:`, error); + } + return backupResult.data as T; + } + + return defaultValue; +} + +/** + * Safely saves a JSON file with atomic write using backup strategy. + * Strategy: write to backup first, then to main file. + * This ensures at least one valid copy exists at all times. + */ +export async function safeSaveJSON( + fs: FileSystem, + filename: string, + base: BaseDir, + data: unknown, +): Promise { + const backupFilename = `${filename}.bak`; + const jsonData = JSON.stringify(data, null, 2); + + try { + await fs.writeFile(backupFilename, base, jsonData); + await fs.writeFile(filename, base, jsonData); + } catch (error) { + console.error(`Failed to save ${filename}:`, error); + throw new Error(`Failed to save ${filename}: ${error}`); + } +} diff --git a/apps/readest-app/src/services/settingsService.ts b/apps/readest-app/src/services/settingsService.ts new file mode 100644 index 00000000..3da13863 --- /dev/null +++ b/apps/readest-app/src/services/settingsService.ts @@ -0,0 +1,109 @@ +import { FileSystem } from '@/types/system'; +import { SystemSettings } from '@/types/settings'; +import { ViewSettings } from '@/types/book'; +import { v4 as uuidv4 } from 'uuid'; +import { + DEFAULT_BOOK_LAYOUT, + DEFAULT_BOOK_STYLE, + DEFAULT_BOOK_FONT, + DEFAULT_BOOK_LANGUAGE, + DEFAULT_VIEW_CONFIG, + DEFAULT_READSETTINGS, + SYSTEM_SETTINGS_VERSION, + DEFAULT_TTS_CONFIG, + DEFAULT_MOBILE_VIEW_SETTINGS, + DEFAULT_SYSTEM_SETTINGS, + DEFAULT_CJK_VIEW_SETTINGS, + DEFAULT_MOBILE_READSETTINGS, + DEFAULT_SCREEN_CONFIG, + DEFAULT_TRANSLATOR_CONFIG, + SETTINGS_FILENAME, + DEFAULT_MOBILE_SYSTEM_SETTINGS, + DEFAULT_ANNOTATOR_CONFIG, + DEFAULT_EINK_VIEW_SETTINGS, +} from './constants'; +import { DEFAULT_AI_SETTINGS } from './ai/constants'; +import { getTargetLang, isCJKEnv } from '@/utils/misc'; +import { safeLoadJSON, safeSaveJSON } from './persistence'; + +export interface Context { + fs: FileSystem; + isMobile: boolean; + isEink: boolean; + isAppDataSandbox: boolean; +} + +export function getDefaultViewSettings(ctx: Context): ViewSettings { + return { + ...DEFAULT_BOOK_LAYOUT, + ...DEFAULT_BOOK_STYLE, + ...DEFAULT_BOOK_FONT, + ...DEFAULT_BOOK_LANGUAGE, + ...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}), + ...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}), + ...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}), + ...DEFAULT_VIEW_CONFIG, + ...DEFAULT_TTS_CONFIG, + ...DEFAULT_SCREEN_CONFIG, + ...DEFAULT_ANNOTATOR_CONFIG, + ...{ ...DEFAULT_TRANSLATOR_CONFIG, translateTargetLang: getTargetLang() }, + }; +} + +export async function loadSettings(ctx: Context): Promise { + const defaultSettings: SystemSettings = { + ...DEFAULT_SYSTEM_SETTINGS, + ...(ctx.isMobile ? DEFAULT_MOBILE_SYSTEM_SETTINGS : {}), + version: SYSTEM_SETTINGS_VERSION, + localBooksDir: await ctx.fs.getPrefix('Books'), + koreaderSyncDeviceId: uuidv4(), + globalReadSettings: { + ...DEFAULT_READSETTINGS, + ...(ctx.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}), + }, + globalViewSettings: getDefaultViewSettings(ctx), + } as SystemSettings; + + let settings = await safeLoadJSON( + ctx.fs, + SETTINGS_FILENAME, + 'Settings', + defaultSettings, + ); + + const version = settings.version ?? 0; + if (ctx.isAppDataSandbox || version < SYSTEM_SETTINGS_VERSION) { + settings.version = SYSTEM_SETTINGS_VERSION; + } + settings = { + ...DEFAULT_SYSTEM_SETTINGS, + ...(ctx.isMobile ? DEFAULT_MOBILE_SYSTEM_SETTINGS : {}), + ...settings, + }; + settings.globalReadSettings = { + ...DEFAULT_READSETTINGS, + ...(ctx.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}), + ...settings.globalReadSettings, + }; + settings.globalViewSettings = { + ...getDefaultViewSettings(ctx), + ...settings.globalViewSettings, + }; + settings.aiSettings = { + ...DEFAULT_AI_SETTINGS, + ...settings.aiSettings, + }; + + settings.localBooksDir = await ctx.fs.getPrefix('Books'); + + if (!settings.kosync.deviceId) { + settings.kosync.deviceId = uuidv4(); + await saveSettings(ctx.fs, settings); + } + + return settings; +} + +export async function saveSettings(fs: FileSystem, settings: SystemSettings): Promise { + await safeSaveJSON(fs, SETTINGS_FILENAME, 'Settings', settings); +} diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts index 5d453b3a..183c89f5 100644 --- a/apps/readest-app/src/services/webAppService.ts +++ b/apps/readest-app/src/services/webAppService.ts @@ -197,6 +197,7 @@ const indexedDBFileSystem: FileSystem = { }, async readDir(path: string, base: BaseDir) { const { fp } = this.resolvePath(path, base); + const prefix = fp.endsWith('/') ? fp : `${fp}/`; const db = await openIndexedDB(); return new Promise((resolve, reject) => { @@ -208,9 +209,9 @@ const indexedDBFileSystem: FileSystem = { const files = request.result as { path: string; content: string | ArrayBuffer | Blob }[]; resolve( files - .filter((file) => file.path.startsWith(fp)) + .filter((file) => file.path.startsWith(prefix)) .map((file) => ({ - path: file.path.slice(fp.length + 1), + path: file.path.slice(prefix.length), size: file.content instanceof Blob ? file.content.size diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 48514767..9dc60132 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -7,7 +7,7 @@ import { CustomTextureInfo } from '@/styles/textures'; import { DatabaseOpts, DatabaseService } from './database'; import { SchemaType } from '@/services/database/migrate'; -export type AppPlatform = 'web' | 'tauri'; +export type AppPlatform = 'web' | 'tauri' | 'node'; export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown'; // prettier-ignore export type BaseDir = | 'Books' | 'Settings' | 'Data' | 'Fonts' | 'Images' | 'Log' | 'Cache' | 'Temp' | 'None'; diff --git a/apps/readest-app/vitest.browser.config.mts b/apps/readest-app/vitest.browser.config.mts index d8891d15..3949b373 100644 --- a/apps/readest-app/vitest.browser.config.mts +++ b/apps/readest-app/vitest.browser.config.mts @@ -1,13 +1,34 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; import { playwright } from '@vitest/browser-playwright'; -import tsconfigPaths from 'vite-tsconfig-paths'; +import { loadEnvFile } from './vitest.env.mts'; + +// Load .env and .env.web so browser tests have the same env as the web app. +const env = { ...loadEnvFile('.env'), ...loadEnvFile('.env.web') }; export default defineConfig({ plugins: [tsconfigPaths()], + define: { + 'process.env': JSON.stringify(env), + }, resolve: { conditions: ['development'], }, optimizeDeps: { + include: [ + 'js-md5', + '@tauri-apps/plugin-fs', + '@tauri-apps/plugin-http', + '@tauri-apps/api/path', + '@tauri-apps/api/core', + '@zip.js/zip.js', + 'franc-min', + 'iso-639-2', + 'iso-639-3', + 'uuid', + 'jwt-decode', + '@supabase/supabase-js', + ], exclude: [ '@tursodatabase/database-wasm', '@tursodatabase/database-wasm-common', @@ -22,6 +43,9 @@ export default defineConfig({ }, test: { include: ['src/**/*.browser.test.ts'], + onConsoleLog(log, type) { + if (type === 'stdout') return false; + }, browser: { enabled: true, provider: playwright(), diff --git a/apps/readest-app/vitest.env.mts b/apps/readest-app/vitest.env.mts new file mode 100644 index 00000000..635daa12 --- /dev/null +++ b/apps/readest-app/vitest.env.mts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; + +export function loadEnvFile(filePath: string): Record { + try { + const content = readFileSync(filePath, 'utf-8'); + const env: Record = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + let value = trimmed.slice(eqIdx + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; + } catch { + return {}; + } +} diff --git a/apps/readest-app/vitest.tauri.config.mts b/apps/readest-app/vitest.tauri.config.mts index f3a29d9e..871048a1 100644 --- a/apps/readest-app/vitest.tauri.config.mts +++ b/apps/readest-app/vitest.tauri.config.mts @@ -1,14 +1,22 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; import { webdriverio } from '@vitest/browser-webdriverio'; -import tsconfigPaths from 'vite-tsconfig-paths'; +import { loadEnvFile } from './vitest.env.mts'; + +// Load .env and .env.tauri so tauri tests have the same env as the desktop app. +const env = { ...loadEnvFile('.env'), ...loadEnvFile('.env.tauri'), CWD: process.cwd() }; export default defineConfig({ plugins: [tsconfigPaths()], + define: { + 'process.env': JSON.stringify(env), + }, resolve: { conditions: ['development'], }, test: { include: ['src/**/*.tauri.test.ts'], + setupFiles: ['./vitest.tauri.setup.ts'], testTimeout: 30000, browser: { enabled: true, diff --git a/apps/readest-app/vitest.tauri.setup.ts b/apps/readest-app/vitest.tauri.setup.ts new file mode 100644 index 00000000..d890fe46 --- /dev/null +++ b/apps/readest-app/vitest.tauri.setup.ts @@ -0,0 +1,82 @@ +// Vitest runs tests inside an iframe, but Tauri injects its plugin internals +// only into the top-level window. Copy them into the iframe so that Tauri +// plugin APIs (e.g. @tauri-apps/plugin-os, @tauri-apps/plugin-fs) work +// in test code that imports them at the module level. +// +// Cross-frame IPC fixes: +// 1. REQUEST: binary args (Uint8Array/ArrayBuffer) created in the iframe fail +// cross-frame `instanceof` checks in Tauri's IPC serializer. We convert +// them to top-window equivalents before forwarding. +// 2. RESPONSE: invoke responses containing ArrayBuffer from the top window fail +// `instanceof ArrayBuffer` in the iframe context. We convert them back. + +const topWindow = (window.top ?? window) as unknown as Record; +const iframeWindow = window as unknown as Record; + +type InvokeFn = (cmd: string, args?: unknown, options?: unknown) => Promise; +type TauriInternals = { invoke: InvokeFn; [key: string]: unknown }; + +const topTauri = topWindow['__TAURI_INTERNALS__'] as TauriInternals | undefined; +if (topTauri && !iframeWindow['__TAURI_INTERNALS__']) { + if (window.top && window.top !== window) { + // We're in an iframe — create a shallow copy with a wrapped invoke + const origInvoke = topTauri.invoke.bind(topTauri); + + // Top window constructors (for cross-frame instanceof checks) + const TopUint8Array = (window.top as unknown as Record)[ + 'Uint8Array' + ] as typeof Uint8Array; + const TopArrayBuffer = (window.top as unknown as Record)[ + 'ArrayBuffer' + ] as typeof ArrayBuffer; + + // Convert iframe-context binary args to top-window equivalents + function fixRequest(args: unknown): unknown { + if (args instanceof Uint8Array && !(args instanceof TopUint8Array)) { + const src = args as Uint8Array; + const fixed = new TopUint8Array(src.length); + fixed.set(src); + return fixed; + } + if (args instanceof ArrayBuffer && !(args instanceof TopArrayBuffer)) { + const src = args as ArrayBuffer; + const fixed = new TopArrayBuffer(src.byteLength); + new TopUint8Array(fixed).set(new Uint8Array(src)); + return fixed; + } + return args; + } + + // Convert top-window ArrayBuffer responses to iframe-context equivalents + function fixResponse(value: unknown): unknown { + if (value instanceof TopArrayBuffer) { + const src = new Uint8Array(value as unknown as ArrayBuffer); + const dst = new ArrayBuffer(src.byteLength); + new Uint8Array(dst).set(src); + return dst; + } + return value; + } + + const wrappedInvoke: InvokeFn = (cmd, args, options) => { + return origInvoke(cmd, fixRequest(args), options).then(fixResponse); + }; + + // Create a shallow copy (original object is frozen) with wrapped invoke + const wrapper: Record = {}; + for (const key of Object.getOwnPropertyNames(topTauri)) { + wrapper[key] = key === 'invoke' ? wrappedInvoke : topTauri[key]; + } + iframeWindow['__TAURI_INTERNALS__'] = wrapper; + } else { + iframeWindow['__TAURI_INTERNALS__'] = topTauri; + } +} + +// Copy other Tauri plugin internals as-is +const otherKeys = ['__TAURI_OS_PLUGIN_INTERNALS__'] as const; +for (const key of otherKeys) { + if (topWindow[key] && !iframeWindow[key]) { + iframeWindow[key] = topWindow[key]; + } +}