diff --git a/apps/readest-app/src/__tests__/services/import-metahash.test.ts b/apps/readest-app/src/__tests__/services/import-metahash.test.ts index f20ba404..ae5e849d 100644 --- a/apps/readest-app/src/__tests__/services/import-metahash.test.ts +++ b/apps/readest-app/src/__tests__/services/import-metahash.test.ts @@ -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); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/node-app-service.test.ts b/apps/readest-app/src/__tests__/services/node-app-service.test.ts index 952a77d5..417efaad 100644 --- a/apps/readest-app/src/__tests__/services/node-app-service.test.ts +++ b/apps/readest-app/src/__tests__/services/node-app-service.test.ts @@ -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 { + 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); }); diff --git a/apps/readest-app/src/__tests__/services/suites/book-tests.ts b/apps/readest-app/src/__tests__/services/suites/book-tests.ts new file mode 100644 index 00000000..10dbcfb2 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/suites/book-tests.ts @@ -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 { + 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, +) { + // 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]); + }); + }); +} diff --git a/apps/readest-app/src/__tests__/services/tauri-app-service.tauri.test.ts b/apps/readest-app/src/__tests__/services/tauri-app-service.tauri.test.ts index 463266e6..0afd7dd9 100644 --- a/apps/readest-app/src/__tests__/services/tauri-app-service.tauri.test.ts +++ b/apps/readest-app/src/__tests__/services/tauri-app-service.tauri.test.ts @@ -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 { + 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); }); diff --git a/apps/readest-app/src/__tests__/services/web-app-service.browser.test.ts b/apps/readest-app/src/__tests__/services/web-app-service.browser.test.ts index 8bbb9cea..fbbd7db6 100644 --- a/apps/readest-app/src/__tests__/services/web-app-service.browser.test.ts +++ b/apps/readest-app/src/__tests__/services/web-app-service.browser.test.ts @@ -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 { + 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); }); diff --git a/apps/readest-app/src/app/api/opds/proxy/route.ts b/apps/readest-app/src/app/api/opds/proxy/route.ts index c7b9b74b..ce7d7386 100644 --- a/apps/readest-app/src/app/api/opds/proxy/route.ts +++ b/apps/readest-app/src/app/api/opds/proxy/route.ts @@ -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', diff --git a/apps/readest-app/src/services/bookService.ts b/apps/readest-app/src/services/bookService.ts index 020010d2..217720e3 100644 --- a/apps/readest-app/src/services/bookService.ts +++ b/apps/readest-app/src/services/bookService.ts @@ -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 { + 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[] = []; + 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(); + 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 = JSON.parse(configData); + // Use aggregated best config when available from deduplication + if (bestConfigData) { + const config: Partial = 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 = 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 = 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