forked from akai/readest
feat(booshelf): dedupe books with the same meta hash (#3535)
This commit is contained in:
@@ -223,7 +223,9 @@ describe('importBook metaHash deduplication', () => {
|
||||
|
||||
// Should return the exact hash match, not the metaHash match
|
||||
expect(result).toBe(exactMatchBook);
|
||||
expect(books.length).toBe(2);
|
||||
// metaHash duplicate should be soft-deleted during aggregation
|
||||
expect(metaMatchBook.deletedAt).toBeTruthy();
|
||||
expect(exactMatchBook.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should not check metaHash for transient imports', async () => {
|
||||
@@ -244,3 +246,330 @@ describe('importBook metaHash deduplication', () => {
|
||||
expect(result).not.toBe(existingBook);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importBook metaHash aggregation', () => {
|
||||
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 remove all duplicates with same metaHash and format', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const book1 = makeBook({ hash: 'hash-1', metaHash });
|
||||
const book2 = makeBook({ hash: 'hash-2', metaHash });
|
||||
const book3 = makeBook({ hash: 'hash-3', metaHash });
|
||||
const unrelated = makeBook({ hash: 'other', metaHash: 'different' });
|
||||
const books: Book[] = [book1, book2, book3, unrelated];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
// Duplicates should be soft-deleted, survivor updated, unrelated untouched
|
||||
const active = books.filter((b) => b.metaHash === metaHash && !b.deletedAt);
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0]!.hash).toBe('new-hash');
|
||||
expect(book2.deletedAt).toBeTruthy();
|
||||
expect(book3.deletedAt).toBeTruthy();
|
||||
expect(unrelated.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should select base config with largest progress pagenum', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const book1 = makeBook({ hash: 'hash-1', metaHash });
|
||||
const book2 = makeBook({ hash: 'hash-2', metaHash });
|
||||
const book3 = makeBook({ hash: 'hash-3', metaHash });
|
||||
const books: Book[] = [book1, book2, book3];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
if (path.endsWith('/config.json')) return true;
|
||||
if (['hash-1', 'hash-2', 'hash-3'].includes(path)) return true;
|
||||
return false;
|
||||
});
|
||||
fs.readFile.mockImplementation(async (path: string) => {
|
||||
if (path === 'hash-1/config.json')
|
||||
return JSON.stringify({ updatedAt: 3000, progress: [10, 200], location: 'loc1' });
|
||||
if (path === 'hash-2/config.json')
|
||||
return JSON.stringify({ updatedAt: 1000, progress: [50, 200], location: 'loc2' });
|
||||
if (path === 'hash-3/config.json')
|
||||
return JSON.stringify({ updatedAt: 2000, progress: [30, 200], location: 'loc3' });
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
const writeCalls = fs.writeFile.mock.calls;
|
||||
const configWrite = writeCalls.find(
|
||||
(c: unknown[]) => (c[0] as string) === 'new-hash/config.json',
|
||||
);
|
||||
expect(configWrite).toBeDefined();
|
||||
const writtenConfig = JSON.parse(configWrite![2] as string);
|
||||
// Base config should be from hash-2 (largest progress page 50)
|
||||
expect(writtenConfig.location).toBe('loc2');
|
||||
expect(writtenConfig.progress).toEqual([50, 200]);
|
||||
});
|
||||
|
||||
it('should merge booknotes with unique id from all configs', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const book1 = makeBook({ hash: 'hash-1', metaHash });
|
||||
const book2 = makeBook({ hash: 'hash-2', metaHash });
|
||||
const books: Book[] = [book1, book2];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
if (path.endsWith('/config.json')) return true;
|
||||
if (['hash-1', 'hash-2'].includes(path)) return true;
|
||||
return false;
|
||||
});
|
||||
fs.readFile.mockImplementation(async (path: string) => {
|
||||
if (path === 'hash-1/config.json')
|
||||
return JSON.stringify({
|
||||
updatedAt: 1000,
|
||||
progress: [80, 200],
|
||||
booknotes: [
|
||||
{
|
||||
id: 'note-a',
|
||||
type: 'annotation',
|
||||
cfi: 'cfi-a',
|
||||
note: 'A',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
{
|
||||
id: 'note-shared',
|
||||
type: 'annotation',
|
||||
cfi: 'cfi-s',
|
||||
note: 'old',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === 'hash-2/config.json')
|
||||
return JSON.stringify({
|
||||
updatedAt: 2000,
|
||||
progress: [20, 200],
|
||||
booknotes: [
|
||||
{ id: 'note-b', type: 'bookmark', cfi: 'cfi-b', note: 'B', createdAt: 2, updatedAt: 2 },
|
||||
{
|
||||
id: 'note-shared',
|
||||
type: 'annotation',
|
||||
cfi: 'cfi-s',
|
||||
note: 'newer',
|
||||
createdAt: 1,
|
||||
updatedAt: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
const writeCalls = fs.writeFile.mock.calls;
|
||||
const configWrite = writeCalls.find(
|
||||
(c: unknown[]) => (c[0] as string) === 'new-hash/config.json',
|
||||
);
|
||||
expect(configWrite).toBeDefined();
|
||||
const writtenConfig = JSON.parse(configWrite![2] as string);
|
||||
// Base should be hash-1 (progress page 80 > 20)
|
||||
expect(writtenConfig.progress).toEqual([80, 200]);
|
||||
// Booknotes should be merged: note-a, note-b, and note-shared (latest updatedAt wins)
|
||||
const notes = writtenConfig.booknotes as Array<{ id: string; note: string; updatedAt: number }>;
|
||||
expect(notes).toHaveLength(3);
|
||||
expect(notes.find((n) => n.id === 'note-a')).toBeDefined();
|
||||
expect(notes.find((n) => n.id === 'note-b')).toBeDefined();
|
||||
const shared = notes.find((n) => n.id === 'note-shared');
|
||||
expect(shared!.note).toBe('newer');
|
||||
expect(shared!.updatedAt).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle configs with missing progress when merging', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const book1 = makeBook({ hash: 'hash-1', metaHash });
|
||||
const book2 = makeBook({ hash: 'hash-2', metaHash });
|
||||
const books: Book[] = [book1, book2];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
if (path.endsWith('/config.json')) return true;
|
||||
if (['hash-1', 'hash-2'].includes(path)) return true;
|
||||
return false;
|
||||
});
|
||||
fs.readFile.mockImplementation(async (path: string) => {
|
||||
if (path === 'hash-1/config.json')
|
||||
return JSON.stringify({ updatedAt: 1000, location: 'loc1' });
|
||||
if (path === 'hash-2/config.json')
|
||||
return JSON.stringify({ updatedAt: 2000, progress: [5, 100], location: 'loc2' });
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
const writeCalls = fs.writeFile.mock.calls;
|
||||
const configWrite = writeCalls.find(
|
||||
(c: unknown[]) => (c[0] as string) === 'new-hash/config.json',
|
||||
);
|
||||
expect(configWrite).toBeDefined();
|
||||
const writtenConfig = JSON.parse(configWrite![2] as string);
|
||||
// hash-2 has progress [5, 100], hash-1 has none (treated as 0) — hash-2 wins
|
||||
expect(writtenConfig.progress).toEqual([5, 100]);
|
||||
expect(writtenConfig.location).toBe('loc2');
|
||||
});
|
||||
|
||||
it('should not aggregate books with different formats', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const epubBook = makeBook({ hash: 'epub-hash', metaHash });
|
||||
const pdfBook = makeBook({
|
||||
hash: 'pdf-hash',
|
||||
metaHash,
|
||||
format: 'PDF' as Book['format'],
|
||||
});
|
||||
const books: Book[] = [epubBook, pdfBook];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc(); // Opens as EPUB
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
// PDF book should not be soft-deleted (different format)
|
||||
expect(pdfBook.deletedAt).toBeNull();
|
||||
// EPUB book should survive (promoted as existing, not a duplicate of itself)
|
||||
expect(epubBook.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should clean up directories of removed duplicates', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const book1 = makeBook({ hash: 'hash-1', metaHash });
|
||||
const book2 = makeBook({ hash: 'hash-2', metaHash });
|
||||
const book3 = makeBook({ hash: 'hash-3', metaHash });
|
||||
const books: Book[] = [book1, book2, book3];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('new-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
return ['hash-2', 'hash-3'].includes(path);
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
// Duplicates should be soft-deleted and their directories cleaned up
|
||||
expect(book2.deletedAt).toBeTruthy();
|
||||
expect(book3.deletedAt).toBeTruthy();
|
||||
const removeDirPaths = fs.removeDir.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(removeDirPaths).toContain('hash-2');
|
||||
expect(removeDirPaths).toContain('hash-3');
|
||||
});
|
||||
|
||||
it('should remove metaHash duplicates even with exact hash match', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const exactMatch = makeBook({ hash: 'exact-hash', metaHash });
|
||||
const dup1 = makeBook({ hash: 'dup-1', metaHash });
|
||||
const dup2 = makeBook({ hash: 'dup-2', metaHash });
|
||||
const books: Book[] = [exactMatch, dup1, dup2];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('exact-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
return ['dup-1', 'dup-2'].includes(path);
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
const result = await service.importBook(mockFile, books);
|
||||
|
||||
expect(result).toBe(exactMatch);
|
||||
expect(exactMatch.deletedAt).toBeNull();
|
||||
expect(dup1.deletedAt).toBeTruthy();
|
||||
expect(dup2.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should merge configs on exact hash match with duplicates', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
|
||||
const exactMatch = makeBook({ hash: 'exact-hash', metaHash });
|
||||
const dup = makeBook({ hash: 'dup-hash', metaHash });
|
||||
const books: Book[] = [exactMatch, dup];
|
||||
|
||||
mockPartialMD5.mockResolvedValue('exact-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockImplementation(async (path: string) => {
|
||||
if (path.endsWith('/config.json')) return true;
|
||||
if (path === 'dup-hash') return true;
|
||||
return false;
|
||||
});
|
||||
fs.readFile.mockImplementation(async (path: string) => {
|
||||
if (path === 'exact-hash/config.json')
|
||||
return JSON.stringify({
|
||||
updatedAt: 1000,
|
||||
progress: [10, 100],
|
||||
booknotes: [
|
||||
{ id: 'n1', type: 'annotation', cfi: 'c1', note: 'x', createdAt: 1, updatedAt: 1 },
|
||||
],
|
||||
});
|
||||
if (path === 'dup-hash/config.json')
|
||||
return JSON.stringify({
|
||||
updatedAt: 5000,
|
||||
progress: [70, 100],
|
||||
location: 'newer',
|
||||
booknotes: [
|
||||
{ id: 'n2', type: 'bookmark', cfi: 'c2', note: 'y', createdAt: 2, updatedAt: 2 },
|
||||
],
|
||||
});
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
await service.importBook(mockFile, books);
|
||||
|
||||
const writeCalls = fs.writeFile.mock.calls;
|
||||
const configWrite = writeCalls.find(
|
||||
(c: unknown[]) => (c[0] as string) === 'exact-hash/config.json',
|
||||
);
|
||||
expect(configWrite).toBeDefined();
|
||||
const writtenConfig = JSON.parse(configWrite![2] as string);
|
||||
// Base config from dup (progress page 70 > 10)
|
||||
expect(writtenConfig.progress).toEqual([70, 100]);
|
||||
expect(writtenConfig.location).toBe('newer');
|
||||
expect(writtenConfig.bookHash).toBe('exact-hash');
|
||||
// Merged booknotes from both
|
||||
expect(writtenConfig.booknotes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,14 @@ import * as path from 'node:path';
|
||||
import { NodeAppService } from '@/services/nodeAppService';
|
||||
import { fsTests } from './suites/fs-tests';
|
||||
import { libraryTests } from './suites/library-tests';
|
||||
import { bookTests } from './suites/book-tests';
|
||||
|
||||
const FIXTURES_DIR = path.join(process.cwd(), 'src/__tests__/fixtures/data');
|
||||
|
||||
async function getBookFile(name: string): Promise<File> {
|
||||
const buf = await fsp.readFile(path.join(FIXTURES_DIR, name));
|
||||
return new File([buf], name);
|
||||
}
|
||||
|
||||
const SANDBOX_DIR = path.join(process.cwd(), '.test-sandbox-node');
|
||||
|
||||
@@ -82,4 +90,5 @@ describe('NodeAppService', () => {
|
||||
|
||||
fsTests(() => service);
|
||||
libraryTests(() => service);
|
||||
bookTests(() => service, getBookFile);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Book, BookNote } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
|
||||
function makeFakeBook(overrides: Partial<Book> = {}): Book {
|
||||
return {
|
||||
hash: 'nonexistent-hash',
|
||||
format: 'EPUB',
|
||||
title: 'Missing Book',
|
||||
author: 'Nobody',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function bookTests(
|
||||
getService: () => AppService,
|
||||
getBookFile: (name: string) => Promise<File | string>,
|
||||
) {
|
||||
// Ensure the Books base directory exists before each book test.
|
||||
// writeFile creates parent dirs, but importBook uses createDir (non-recursive).
|
||||
beforeEach(async () => {
|
||||
await getService().createDir('', 'Books', true);
|
||||
});
|
||||
|
||||
describe('Book import', () => {
|
||||
it('should import an EPUB book with correct metadata', async () => {
|
||||
const service = getService();
|
||||
const file = await getBookFile('sample-alice.epub');
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(file, books);
|
||||
|
||||
expect(book).not.toBeNull();
|
||||
expect(book!.format).toBe('EPUB');
|
||||
expect(book!.hash).toBeTruthy();
|
||||
expect(book!.title).toBeTruthy();
|
||||
expect(book!.author).toBeTruthy();
|
||||
expect(books).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should deduplicate when importing the same file twice', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
|
||||
const file1 = await getBookFile('sample-alice.epub');
|
||||
const first = await service.importBook(file1, books);
|
||||
|
||||
const file2 = await getBookFile('sample-alice.epub');
|
||||
const second = await service.importBook(file2, books);
|
||||
|
||||
expect(books).toHaveLength(1);
|
||||
expect(second!.hash).toBe(first!.hash);
|
||||
});
|
||||
|
||||
it('should not add duplicate when reimporting with overwrite', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
|
||||
const file1 = await getBookFile('sample-alice.epub');
|
||||
const first = await service.importBook(file1, books);
|
||||
|
||||
const file2 = await getBookFile('sample-alice.epub');
|
||||
const second = await service.importBook(file2, books, true, true, true);
|
||||
|
||||
expect(books).toHaveLength(1);
|
||||
expect(second!.hash).toBe(first!.hash);
|
||||
expect(second!.updatedAt).toBeGreaterThanOrEqual(first!.updatedAt);
|
||||
});
|
||||
|
||||
it('should set hash and timestamps on imported book', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const before = Date.now();
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
expect(book!.hash).toBeTruthy();
|
||||
expect(book!.createdAt).toBeGreaterThanOrEqual(before);
|
||||
expect(book!.updatedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(book!.downloadedAt).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Book availability', () => {
|
||||
it('should report imported book as available', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
expect(await service.isBookAvailable(book!)).toBe(true);
|
||||
});
|
||||
|
||||
it('should report non-existent book as unavailable', async () => {
|
||||
const service = getService();
|
||||
expect(await service.isBookAvailable(makeFakeBook())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Book file size', () => {
|
||||
it('should return file size for imported book', async () => {
|
||||
const service = getService();
|
||||
const file = await getBookFile('sample-alice.epub');
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(file, books);
|
||||
|
||||
const size = await service.getBookFileSize(book!);
|
||||
expect(size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return null for non-existent book', async () => {
|
||||
const service = getService();
|
||||
expect(await service.getBookFileSize(makeFakeBook())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Book content', () => {
|
||||
it('should load content for imported book', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
const content = await service.loadBookContent(book!);
|
||||
expect(content.book.hash).toBe(book!.hash);
|
||||
expect(content.file).toBeDefined();
|
||||
expect(content.file.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should throw when loading content for non-existent book', async () => {
|
||||
const service = getService();
|
||||
await expect(service.loadBookContent(makeFakeBook())).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Book config', () => {
|
||||
it('should load default config for newly imported book', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
const settings = await service.loadSettings();
|
||||
const config = await service.loadBookConfig(book!, settings);
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.updatedAt).toBeDefined();
|
||||
expect(config.viewSettings).toBeDefined();
|
||||
expect(config.searchConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it('should save and load book config with progress and location', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
const settings = await service.loadSettings();
|
||||
const config = await service.loadBookConfig(book!, settings);
|
||||
|
||||
config.location = 'epubcfi(/6/4!/4/2/1:0)';
|
||||
config.progress = [5, 100];
|
||||
|
||||
await service.saveBookConfig(book!, config, settings);
|
||||
const loaded = await service.loadBookConfig(book!, settings);
|
||||
|
||||
expect(loaded.location).toBe('epubcfi(/6/4!/4/2/1:0)');
|
||||
expect(loaded.progress).toEqual([5, 100]);
|
||||
});
|
||||
|
||||
it('should save and load book config with annotations', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
const settings = await service.loadSettings();
|
||||
const config = await service.loadBookConfig(book!, settings);
|
||||
|
||||
const note: BookNote = {
|
||||
id: 'note-1',
|
||||
type: 'annotation',
|
||||
cfi: 'epubcfi(/6/4!/4/2/1:0)',
|
||||
note: 'Test annotation',
|
||||
style: 'highlight',
|
||||
color: 'yellow',
|
||||
text: 'highlighted text',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
config.booknotes = [note];
|
||||
|
||||
await service.saveBookConfig(book!, config, settings);
|
||||
const loaded = await service.loadBookConfig(book!, settings);
|
||||
|
||||
expect(loaded.booknotes).toHaveLength(1);
|
||||
expect(loaded.booknotes![0]!.id).toBe('note-1');
|
||||
expect(loaded.booknotes![0]!.note).toBe('Test annotation');
|
||||
expect(loaded.booknotes![0]!.text).toBe('highlighted text');
|
||||
});
|
||||
|
||||
it('should save config without settings and load with settings', async () => {
|
||||
const service = getService();
|
||||
const books: Book[] = [];
|
||||
const book = await service.importBook(await getBookFile('sample-alice.epub'), books);
|
||||
|
||||
const rawConfig = {
|
||||
updatedAt: Date.now(),
|
||||
location: 'raw-location',
|
||||
progress: [10, 200] as [number, number],
|
||||
};
|
||||
|
||||
await service.saveBookConfig(book!, rawConfig);
|
||||
const settings = await service.loadSettings();
|
||||
const loaded = await service.loadBookConfig(book!, settings);
|
||||
|
||||
expect(loaded.location).toBe('raw-location');
|
||||
expect(loaded.progress).toEqual([10, 200]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import { mkdir, remove, writeTextFile, readTextFile } from '@tauri-apps/plugin-f
|
||||
import { NativeAppService } from '@/services/nativeAppService';
|
||||
import { fsTests } from './suites/fs-tests';
|
||||
import { libraryTests } from './suites/library-tests';
|
||||
import { bookTests } from './suites/book-tests';
|
||||
|
||||
async function getBookFile(name: string): Promise<string> {
|
||||
return await join(process.env['CWD']!, 'src/__tests__/fixtures/data', name);
|
||||
}
|
||||
|
||||
const SANDBOX_DIR = `${process.env['CWD']}/.readest-test-sandbox-tauri`;
|
||||
let tmpCounter = 0;
|
||||
@@ -60,4 +65,5 @@ describe('NativeAppService', () => {
|
||||
|
||||
fsTests(() => service);
|
||||
libraryTests(() => service);
|
||||
bookTests(() => service, getBookFile);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,14 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { WebAppService } from '@/services/webAppService';
|
||||
import { fsTests } from './suites/fs-tests';
|
||||
import { libraryTests } from './suites/library-tests';
|
||||
import { bookTests } from './suites/book-tests';
|
||||
|
||||
async function getBookFile(name: string): Promise<File> {
|
||||
const url = new URL(`../fixtures/data/${name}`, import.meta.url).href;
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return new File([blob], name, { type: blob.type });
|
||||
}
|
||||
|
||||
/** Clear all records from the IndexedDB object store without deleting the database. */
|
||||
async function clearStore() {
|
||||
@@ -55,4 +63,5 @@ describe('WebAppService', () => {
|
||||
|
||||
fsTests(() => service);
|
||||
libraryTests(() => service);
|
||||
bookTests(() => service, getBookFile);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
||||
status: 403,
|
||||
headers: {
|
||||
...Object.fromEntries(response.headers.entries()),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Cache-Control': 'no-store',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
@@ -76,7 +76,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
||||
status: 403,
|
||||
headers: {
|
||||
...Object.fromEntries(response.headers.entries()),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Cache-Control': 'no-store',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
@@ -87,7 +87,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
||||
status: response.status,
|
||||
headers: {
|
||||
...Object.fromEntries(response.headers.entries()),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Cache-Control': 'no-store',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { FileSystem, AppPlatform, BaseDir } from '@/types/system';
|
||||
import { Book, BookConfig, BookContent, BookFormat, FIXED_LAYOUT_FORMATS } from '@/types/book';
|
||||
import {
|
||||
Book,
|
||||
BookConfig,
|
||||
BookContent,
|
||||
BookFormat,
|
||||
BookNote,
|
||||
FIXED_LAYOUT_FORMATS,
|
||||
} from '@/types/book';
|
||||
import {
|
||||
getDir,
|
||||
getLocalBookFilename,
|
||||
@@ -24,8 +31,6 @@ 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;
|
||||
@@ -105,6 +110,76 @@ export async function updateCoverImage(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Book Merge ---
|
||||
|
||||
/**
|
||||
* Merge duplicate book entries that share the same metaHash and format as `book`.
|
||||
* Finds all other matching books in the array, selects the base config with the
|
||||
* largest reading progress page number, merges booknotes from all configs
|
||||
* (deduplicating by id, latest updatedAt wins), soft-deletes duplicates
|
||||
* (sets deletedAt), and cleans up their directories.
|
||||
*
|
||||
* @returns The merged config as a JSON string, or undefined if no duplicates were found.
|
||||
*/
|
||||
export async function mergeBooks(
|
||||
fs: FileSystem,
|
||||
books: Book[],
|
||||
book: Book,
|
||||
): Promise<string | undefined> {
|
||||
if (!book.metaHash) return undefined;
|
||||
|
||||
const duplicates = books.filter(
|
||||
(b) => b.metaHash === book.metaHash && b.format === book.format && !b.deletedAt && b !== book,
|
||||
);
|
||||
if (duplicates.length === 0) return undefined;
|
||||
|
||||
const allCandidates = [book, ...duplicates];
|
||||
const configs: Partial<BookConfig>[] = [];
|
||||
for (const candidate of allCandidates) {
|
||||
const configPath = getConfigFilename(candidate);
|
||||
if (await fs.exists(configPath, 'Books')) {
|
||||
try {
|
||||
const str = (await fs.readFile(configPath, 'Books', 'text')) as string;
|
||||
configs.push(JSON.parse(str));
|
||||
} catch {
|
||||
/* ignore corrupt configs */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mergedConfigData: string | undefined;
|
||||
if (configs.length > 0) {
|
||||
const base = configs.reduce((best, cfg) => {
|
||||
const bestPage = best.progress?.[0] ?? 0;
|
||||
const cfgPage = cfg.progress?.[0] ?? 0;
|
||||
return cfgPage > bestPage ? cfg : best;
|
||||
});
|
||||
|
||||
const noteMap = new Map<string, BookNote>();
|
||||
for (const cfg of configs) {
|
||||
for (const note of cfg.booknotes ?? []) {
|
||||
const existing = noteMap.get(note.id);
|
||||
if (!existing || (note.updatedAt || 0) > (existing.updatedAt || 0)) {
|
||||
noteMap.set(note.id, note);
|
||||
}
|
||||
}
|
||||
}
|
||||
base.booknotes = [...noteMap.values()];
|
||||
|
||||
mergedConfigData = JSON.stringify(base);
|
||||
}
|
||||
|
||||
for (const dup of duplicates) {
|
||||
dup.deletedAt = Date.now();
|
||||
const dupDir = getDir(dup);
|
||||
if (await fs.exists(dupDir, 'Books')) {
|
||||
await fs.removeDir(dupDir, 'Books', true);
|
||||
}
|
||||
}
|
||||
|
||||
return mergedConfigData;
|
||||
}
|
||||
|
||||
// --- Book Import ---
|
||||
|
||||
export async function importBook(
|
||||
@@ -174,15 +249,23 @@ export async function importBook(
|
||||
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();
|
||||
// Aggregate all books with same metaHash and format, deduplicating into one entry
|
||||
let bestConfigData: string | undefined;
|
||||
if (!transient && metaHash) {
|
||||
if (!existingBook) {
|
||||
const firstMatch = books.find(
|
||||
(b) => b.metaHash === metaHash && b.format === format && !b.deletedAt,
|
||||
);
|
||||
if (firstMatch) {
|
||||
oldBookDir = getDir(firstMatch);
|
||||
existingBook = firstMatch;
|
||||
metaHashMatch = true;
|
||||
existingBook.createdAt = Date.now();
|
||||
existingBook.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
if (existingBook) {
|
||||
bestConfigData = await mergeBooks(fs, books, existingBook);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,20 +359,34 @@ export async function importBook(
|
||||
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);
|
||||
// Use aggregated best config when available from deduplication
|
||||
if (bestConfigData) {
|
||||
const config: Partial<BookConfig> = JSON.parse(bestConfigData);
|
||||
config.bookHash = hash;
|
||||
config.metaHash = metaHash;
|
||||
await fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config));
|
||||
} else {
|
||||
await saveBookConfigFn(book, INIT_BOOK_CONFIG);
|
||||
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);
|
||||
}
|
||||
} else if (bestConfigData) {
|
||||
// Exact hash match with duplicates removed — adopt the best config
|
||||
const config: Partial<BookConfig> = JSON.parse(bestConfigData);
|
||||
config.bookHash = hash;
|
||||
config.metaHash = metaHash;
|
||||
await fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config));
|
||||
}
|
||||
|
||||
// update file links with url or path or content uri
|
||||
|
||||
Reference in New Issue
Block a user