feat(booshelf): dedupe books with the same meta hash (#3535)

This commit is contained in:
Huang Xin
2026-03-14 22:14:22 +08:00
committed by GitHub
parent 25352efece
commit db3dee025b
7 changed files with 687 additions and 21 deletions
@@ -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',
+114 -17
View File
@@ -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