feat(node): refactor appService into focused service modules and add app service for Node runtime (#3530)

This also closes #3431.
This commit is contained in:
Huang Xin
2026-03-14 00:51:35 +08:00
committed by GitHub
parent ed5bbd25d6
commit 8274c6bc4f
23 changed files with 2342 additions and 780 deletions
@@ -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",
@@ -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<typeof import('@/utils/md5')>('@/utils/md5');
return { ...actual, partialMD5: mockPartialMD5 };
});
vi.mock('@/libs/document', async () => {
const actual = await vi.importActual<typeof import('@/libs/document')>('@/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<BaseAppService['openDatabase']>;
}
async createWindow() {}
async getCacheDir() {
return '';
}
async clearWebviewCache() {}
async showNotification() {}
getFs() {
return this.fs;
}
}
function makeBook(overrides: Partial<Book> = {}): 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<string, unknown> = 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);
});
});
@@ -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);
});
@@ -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');
});
});
}
@@ -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> = {}): 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([]);
});
});
}
@@ -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);
});
@@ -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<void>((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);
});
+113 -770
View File
@@ -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<void> {
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<string> => {
return this.fs.getBlobURL(`${this.localBooksDir}/${getCoverFilename(book)}`, 'None');
};
async getCachedImageUrl(pathOrUrl: string): Promise<string> {
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<SystemSettings> {
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<SystemSettings>(
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<void> {
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<string> =>
BookSvc.getCoverImageBlobUrl(this.coverCtx, book);
async getCachedImageUrl(pathOrUrl: string): Promise<string> {
return BookSvc.getCachedImageUrl(this.coverCtx, pathOrUrl);
}
async generateCoverImageUrl(book: Book): Promise<string> {
return BookSvc.generateCoverImageUrl(this.coverCtx, book);
}
async updateCoverImage(book: Book, imageUrl?: string, imageFile?: string): Promise<void> {
return BookSvc.updateCoverImage(this.coverCtx, book, imageUrl, imageFile);
}
async importFont(file?: string | File): Promise<CustomFontInfo | null> {
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<void> {
await this.fs.removeFile(font.path, 'Fonts');
return FontSvc.deleteFont(this.fs, font);
}
async importImage(file?: string | File): Promise<CustomTextureInfo | null> {
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<void> {
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<Book | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<number | null> {
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<BookContent> {
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<BookConfig> {
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<string> {
return this.appPlatform === 'web'
? await this.getCoverImageBlobUrl(book)
: this.getCoverImageUrl(book);
return BookSvc.saveBookConfig(this.fs, book, config, settings);
}
async loadLibraryBooks(): Promise<Book[]> {
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<Book[]>(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<void> {
// 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<ArrayBuffer> {
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<void> {
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<T>(filename: string, base: BaseDir, defaultValue: T): Promise<T> {
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<void> {
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<void> {
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);
}
}
@@ -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<string> {
return ctx.fs.getBlobURL(`${ctx.localBooksDir}/${getCoverFilename(book)}`, 'None');
}
export async function getCachedImageUrl(ctx: CoverContext, pathOrUrl: string): Promise<string> {
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<string> {
return ctx.appPlatform === 'web'
? await getCoverImageBlobUrl(ctx, book)
: getCoverImageUrl(ctx, book);
}
function imageToArrayBuffer(
ctx: CoverContext,
imageUrl?: string,
imageFile?: string,
): Promise<ArrayBuffer> {
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<void> {
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<void>,
generateCoverImageUrlFn: (book: Book) => Promise<string>,
): Promise<Book | null> {
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<BookConfig> = 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<boolean> {
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<number | null> {
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<BookContent> {
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<BookConfig> {
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<void> {
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<void>,
): Promise<BookDoc['metadata']> {
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<string>,
copyFile: (srcPath: string, dstPath: string, base: BaseDir) => Promise<void>,
saveFile: (
filename: string,
content: ArrayBuffer,
filepath: string,
mimeType?: string,
) => Promise<boolean>,
): Promise<boolean> {
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);
}
@@ -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<void> {
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<string>,
lfp: string,
cfp: string,
base: BaseDir,
handleProgress: ProgressHandler,
hash: string,
temp: boolean = false,
): Promise<string | undefined> {
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<string>,
book: Book,
onProgress?: ProgressHandler,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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();
}
}
@@ -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<CustomFontInfo | null> {
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<void> {
await fs.removeFile(font.path, 'Fonts');
}
@@ -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<CustomTextureInfo | null> {
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<void> {
await fs.removeFile(texture.path, 'Images');
}
@@ -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<string>,
): Promise<Book[]> {
const libraryFilename = getLibraryFilename();
if (!(await fs.exists('', 'Books'))) {
await fs.createDir('', 'Books', true);
}
const books = await safeLoadJSON<Book[]>(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<void> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest);
await safeSaveJSON(fs, getLibraryFilename(), 'Books', libraryBooks);
}
@@ -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<string>('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,
});
@@ -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<string> {
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<File> {
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<void> {
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<string | ArrayBuffer> {
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<void> {
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<void> {
const fullPath = await toAbsolute(this.resolvePath(filePath, base));
await fsp.unlink(fullPath);
},
async createDir(dirPath: string, base: BaseDir, recursive = false): Promise<void> {
const fullPath = await toAbsolute(this.resolvePath(dirPath, base));
await fsp.mkdir(fullPath, { recursive });
},
async removeDir(dirPath: string, base: BaseDir, recursive = false): Promise<void> {
const fullPath = await toAbsolute(this.resolvePath(dirPath, base));
await fsp.rm(fullPath, { recursive, force: recursive });
},
async readDir(dirPath: string, base: BaseDir): Promise<FileItem[]> {
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<boolean> {
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<FileInfo> {
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<void> {
await this.prepareBooksDir();
}
async setCustomRootDir(customRootDir: string): Promise<void> {
this.fs.resolvePath = getPathResolver({ customRootDir: nodePath.resolve(customRootDir) });
await this.prepareBooksDir();
}
async selectDirectory(): Promise<string> {
throw new Error('selectDirectory is not supported in Node.js environment');
}
async selectFiles(): Promise<string[]> {
throw new Error('selectFiles is not supported in Node.js environment');
}
async saveFile(
_filename: string,
content: string | ArrayBuffer,
filepath: string,
): Promise<boolean> {
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<boolean> {
return false;
}
async openDatabase(
schema: SchemaType,
path: string,
base: BaseDir,
opts?: DatabaseOpts,
): Promise<DatabaseService> {
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;
}
}
@@ -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<T>(
fs: FileSystem,
filename: string,
base: BaseDir,
defaultValue: T,
): Promise<T> {
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<void> {
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}`);
}
}
@@ -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<SystemSettings> {
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<SystemSettings>(
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<void> {
await safeSaveJSON(fs, SETTINGS_FILENAME, 'Settings', settings);
}
@@ -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<FileItem[]>((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
+1 -1
View File
@@ -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';
+25 -1
View File
@@ -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(),
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs';
export function loadEnvFile(filePath: string): Record<string, string> {
try {
const content = readFileSync(filePath, 'utf-8');
const env: Record<string, string> = {};
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 {};
}
}
+9 -1
View File
@@ -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,
+82
View File
@@ -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<string, unknown>;
const iframeWindow = window as unknown as Record<string, unknown>;
type InvokeFn = (cmd: string, args?: unknown, options?: unknown) => Promise<unknown>;
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<string, unknown>)[
'Uint8Array'
] as typeof Uint8Array;
const TopArrayBuffer = (window.top as unknown as Record<string, unknown>)[
'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<string, unknown> = {};
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];
}
}