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:
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user