From 80cab8e56d297edc0346b0942c2bb83183fb2987 Mon Sep 17 00:00:00 2001 From: AK Venugopal <97830569+AnandakrishnanV@users.noreply.github.com> Date: Fri, 3 Apr 2026 08:06:43 +0100 Subject: [PATCH] feat: Hardcover.app Sync (#3724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(hardcover): add one-way Hardcover sync integration - Add HardcoverClient with ISBN/title-based book lookup, progress push, and note sync - Add HardcoverSyncMapStore for persistent local mapping of note IDs to Hardcover journal IDs - Add GraphQL queries/mutations for Hardcover API (insert/update/recreate journal entries) - Add DB migration for hardcover_note_mappings table (schema: hardcover-sync) - Add HardcoverSettings dialog component (connect/disconnect/enable toggle) - Add useHardcoverSync hook wired in Annotator for push-notes and push-progress events - Add Hardcover Sync section in BookMenu with per-book toggle (persisted in BookConfig) - Add HardcoverSettings type and DEFAULT_HARDCOVER_SETTINGS to system settings - Add hardcoverSyncEnabled per-book flag to BookConfig - Mount HardcoverSettingsWindow in Reader alongside KOSync and Readwise windows Sync is one-way (Readest → Hardcover), manual-action only, per-book opt-in. Supports idempotent note sync: insert new, skip unchanged, update changed, recreate on stale remote ID. * fix(hardcover): proxy API calls server-side to fix CORS, normalize Bearer token - Add /api/hardcover/graphql Next.js route that proxies POST requests server-side, bypassing CORS restrictions (Hardcover API has no Access-Control-Allow-Origin header) - On Tauri (desktop), calls Hardcover directly; on web, routes through the proxy - Normalize token in HardcoverClient: accept raw JWT or 'Bearer ' format - Update helper text to point to hardcover.app → Settings → API * fix(hardcover): surface note-sync no-ops and harden book resolution - Show toast feedback when book data is still loading, Hardcover is not configured, or the current book has no annotations/excerpts to sync - Show an explicit info toast when note sync finds no new changes - Parse Hardcover search results in the current hits/document response shape - Resolve note sync through ensureBookInLibrary for parity with progress sync - Add console logging for note/progress sync failures * debug(hardcover): add runtime instrumentation for note sync * feat(metadata): keep identifier stable and store ISBN separately - Add dedicated metadata.isbn field - Expose ISBN as its own editable field in book metadata - Preserve identifier semantics for existing source IDs and hashes - Route metadata auto-retrieval ISBN handling through the new field - Prefer metadata.isbn for Hardcover matching * fix(hardcover): avoid wasm sqlite for note mappings on web - Store Hardcover note sync mappings in localStorage on web - Keep sqlite-backed mappings for desktop/native environments - Remove the web-only database dependency from manual note sync * fix(hardcover): dedupe notes by payload hash across unstable note IDs - Add payload-hash lookup in HardcoverSyncMapStore - Reuse existing journal mapping when payload already synced - Prevent duplicate insertions when note IDs change or duplicate locally * fix(hardcover): avoid duplicate quote export when annotation note exists - Detect excerpt+annotation pairs for the same highlight - Skip standalone excerpt export when annotation has note text - Keep annotation export as the single source of truth * docs(hardcover): add consolidated change summary for review * fix(hardcover): suppress excerpt export by CFI when annotation note exists * fix(hardcover): suppress empty-note annotation duplicates when note exists * fix(hardcover): deduplicate notes by text and cfi base node * refactor(hardcover): optimize sync performance, add rate limiting and clean up debug tools * chore: remove dev-only change log from main * test(hardcover): add unit tests for sync mapping and client logic * chore: custom deployment and UI fixes * fix(hardcover): use timestamptz for accurate annotation time * fix(hardcover): use date scalar and RFC 3339 formatting for journal entries * Revert "chore: custom deployment and UI fixes" This reverts commit 0329aba7129d1e1ebf2c663804b8fba9a9f87b91. * Fix hardcover progress dates and surface imported ISBNs * Fix Hardcover currently reading sync * fix(hardcover): avoid promoting note sync status * style(hardcover): apply prettier formatting * test(hardcover): fix strict TypeScript assertions * test(hardcover): harden sync regression coverage * test(hardcover): fix lint and formatting regressions * fix(hardcover): narrow note dedupe range matching * refactor(hardcover): extract note dedupe helpers * refactor(isbn): extract metadata normalization helpers * feat(hardcover): improve synced quote formatting --- .../hardcover/HardcoverClient.test.ts | 392 ++++++++++++ .../hardcover/HardcoverSyncMapStore.test.ts | 174 ++++++ .../services/import-metahash.test.ts | 20 + .../src/app/api/hardcover/graphql/route.ts | 37 ++ .../reader/components/HardcoverSettings.tsx | 166 ++++++ .../src/app/reader/components/Reader.tsx | 2 + .../reader/components/annotator/Annotator.tsx | 2 + .../reader/components/sidebar/BookMenu.tsx | 59 ++ .../src/app/reader/hooks/useHardcoverSync.ts | 158 +++++ .../components/metadata/BookDetailEdit.tsx | 8 +- .../components/metadata/SourceSelector.tsx | 6 +- .../components/metadata/useMetadataEdit.ts | 28 +- apps/readest-app/src/libs/document.ts | 1 + apps/readest-app/src/services/bookService.ts | 2 + apps/readest-app/src/services/constants.ts | 8 + .../src/services/database/migrations/index.ts | 34 +- .../src/services/hardcover/HardcoverClient.ts | 559 ++++++++++++++++++ .../hardcover/HardcoverSyncMapStore.ts | 157 +++++ .../services/hardcover/hardcover-graphql.ts | 135 +++++ .../src/services/hardcover/index.ts | 2 + .../src/services/metadata/types.ts | 1 + apps/readest-app/src/types/book.ts | 3 + apps/readest-app/src/types/settings.ts | 7 + apps/readest-app/src/utils/isbn.ts | 58 ++ 24 files changed, 1998 insertions(+), 21 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts create mode 100644 apps/readest-app/src/__tests__/services/hardcover/HardcoverSyncMapStore.test.ts create mode 100644 apps/readest-app/src/app/api/hardcover/graphql/route.ts create mode 100644 apps/readest-app/src/app/reader/components/HardcoverSettings.tsx create mode 100644 apps/readest-app/src/app/reader/hooks/useHardcoverSync.ts create mode 100644 apps/readest-app/src/services/hardcover/HardcoverClient.ts create mode 100644 apps/readest-app/src/services/hardcover/HardcoverSyncMapStore.ts create mode 100644 apps/readest-app/src/services/hardcover/hardcover-graphql.ts create mode 100644 apps/readest-app/src/services/hardcover/index.ts create mode 100644 apps/readest-app/src/utils/isbn.ts diff --git a/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts b/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts new file mode 100644 index 00000000..250db25d --- /dev/null +++ b/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts @@ -0,0 +1,392 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { HardcoverClient } from '@/services/hardcover/HardcoverClient'; +import type { HardcoverSyncMapStore } from '@/services/hardcover/HardcoverSyncMapStore'; +import type { Book, BookConfig, BookNote } from '@/types/book'; + +type MockFetchResponse = { + ok: boolean; + status?: number; + statusText?: string; + json: () => Promise; +}; + +type MockFetch = ReturnType Promise>>; + +type TestBookContext = { + editionId: number; + pages: number | null; + bookId: number; + bookPages: number | null; + userBook: { + id: number; + status_id: number; + user_book_reads: Array<{ id: number; started_at?: string | null }>; + } | null; +}; + +type HardcoverClientTestApi = { + token: string; + extractISBN: (book: Book) => string | null; + request: (query: string, variables: TVariables) => Promise; + buildJournalPayload: ( + note: BookNote, + config: BookConfig, + context: TestBookContext, + ) => { action_at: string; entry: string; event: string }; + ensureBookInLibrary: (book: Book, isReading?: boolean) => Promise; + pushProgress: (book: Book, config: BookConfig) => Promise; +}; + +type RequestSpyCall = [query: string, variables?: unknown]; +type FetchMockCall = [input: unknown, init?: { body?: string }]; + +describe('HardcoverClient', () => { + let mockMapStore: HardcoverSyncMapStore; + let client: HardcoverClient; + let clientApi: HardcoverClientTestApi; + let fetchMock: MockFetch; + const mockSettings = { accessToken: 'test-token' }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock HardcoverSyncMapStore + mockMapStore = { + getMapping: vi.fn().mockResolvedValue(null), + getMappingByPayloadHash: vi.fn().mockResolvedValue(null), + upsertMapping: vi.fn().mockResolvedValue(undefined), + flush: vi.fn().mockResolvedValue(undefined), + loadForBook: vi.fn().mockResolvedValue(undefined), + } as unknown as HardcoverSyncMapStore; + + // Mock global fetch + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: { me: { id: 1 } } }), + }); + vi.stubGlobal('fetch', fetchMock); + + client = new HardcoverClient(mockSettings, mockMapStore); + clientApi = client as unknown as HardcoverClientTestApi; + }); + + test('should normalize accessToken correctly', () => { + const rawClient = new HardcoverClient({ accessToken: 'raw-jwt' }, mockMapStore); + expect((rawClient as unknown as HardcoverClientTestApi).token).toBe('Bearer raw-jwt'); + + const bearClient = new HardcoverClient({ accessToken: 'Bearer already-has' }, mockMapStore); + expect((bearClient as unknown as HardcoverClientTestApi).token).toBe('Bearer already-has'); + }); + + test('should extract ISBN from metadata', () => { + const book = { + metadata: { + isbn: '0743273567', + }, + } as unknown as Book; + + const isbn = clientApi.extractISBN(book); + expect(isbn).toBe('0743273567'); + }); + + test('should extract ISBN from alternative identifiers', () => { + const book = { + metadata: { + identifier: [{ scheme: 'ISBN', value: '9780679783268' }, 'urn:isbn:0679783261'], + }, + } as unknown as Book; + + const isbn = clientApi.extractISBN(book); + expect(isbn).toBe('9780679783268'); + }); + + test('should deduplicate notes correctly in syncBookNotes', async () => { + const book = { + hash: 'book-hash', + title: 'Test Book', + author: 'Test', + metadata: { isbn: '1234567890' }, // Add ISBN to trigger QUERY_GET_EDITION + } as unknown as Book; + + const config = { + booknotes: [ + { + id: 'note-1', + type: 'annotation', + text: 'Shared Text', + note: 'Some note', + cfi: 'epubcfi(/6/4[chap1]!/4/2,/1:10,/1:22)', + }, + { + id: 'note-2', + type: 'excerpt', + text: 'Shared Text', + cfi: 'epubcfi(/6/4[chap1]!/4/2,/1:10,/1:23)', // Slightly different end offset only + }, + { + id: 'note-3', + type: 'annotation', + text: 'Other Text', + note: '', + }, + ] as BookNote[], + } as BookConfig; + + // Setup mocks for authenticate & fetch context & insert + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { me: { id: 1 } } }), // authenticate + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + editions: [ + { + id: 101, + book: { + id: 202, + user_books: [ + { + id: 303, + user_book_reads: [], + }, + ], + }, + }, + ], + }, + }), // fetchContext (QUERY_GET_EDITION) + }); + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { insert_reading_journal: { id: 999 } } }), // generic inserts + }); + + const results = await client.syncBookNotes(book, config); + + // note-1: kept (annotation with note) + // note-2: skipped (excerpt at same location/text as note-1) + // note-3: kept (annotation with no note, but no conflicts) + expect(results.inserted).toBe(2); + expect(results.skipped).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(mockMapStore.flush).toHaveBeenCalled(); + }); + + test('should handle rate limiting with retries', async () => { + // request() does NOT call authenticate() so only 2 mock values are needed + + // First request fails with 429 then succeeds + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + json: async () => ({}), + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { result: 'ok' } }), + }); + + // Speed up sleep for test + vi.useFakeTimers(); + const requestPromise = clientApi.request<{ var: number }, { result: string }>('query', { + var: 1, + }); + + // Wait for the 429 retry + await vi.runAllTimersAsync(); + const result = await requestPromise; + + expect(result).toEqual({ result: 'ok' }); + vi.useRealTimers(); + }); + + test('should produce the expected date formats for journal and progress payloads', async () => { + const note = { + updatedAt: 1711737600000, // 2026-03-29 ... + type: 'annotation', + text: 'Test', + id: '1', + } as BookNote; + const config = { progress: [5, 100] } as BookConfig; + const context: TestBookContext = { + editionId: 2, + pages: 100, + bookId: 1, + bookPages: 100, + userBook: null, + }; + + // Test journal payload + const payload = clientApi.buildJournalPayload(note, config, context); + // Should be full ISO (e.g. 2026-03-29T16:00:00.000Z), length > 20 + expect(payload.action_at.length).toBeGreaterThan(10); + expect(payload.action_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + // Test progress payload (started_at) + const book = { createdAt: 1711737600000 } as Book; + vi.spyOn(clientApi, 'ensureBookInLibrary').mockResolvedValue({ + editionId: 2, + pages: 100, + bookId: 1, + bookPages: 100, + userBook: { + id: 3, + status_id: 2, + user_book_reads: [], + }, + }); + const requestSpy = vi.spyOn(clientApi, 'request').mockResolvedValue({}); + await clientApi.pushProgress(book, config); + + const requestCalls = requestSpy.mock.calls as RequestSpyCall[]; + const progressCall = requestCalls.find((call) => { + const query = call[0]; + return typeof query === 'string' && query.includes('mutation InsertRead'); + }); + expect(progressCall).toBeDefined(); + const variables = progressCall?.[1] as { started_at?: string } | undefined; + expect(variables?.started_at).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + test('should format multiline quote text with a short divider before the note', () => { + const note = { + id: '1', + type: 'annotation', + text: "She smiled. 'Are you, Overseer? Still?'\n\n'What do you mean?'", + note: 'Follow-up note', + } as BookNote; + const config = { progress: [5, 100] } as BookConfig; + const context: TestBookContext = { + editionId: 2, + pages: 100, + bookId: 1, + bookPages: 100, + userBook: null, + }; + + const payload = clientApi.buildJournalPayload(note, config, context); + + expect(payload.event).toBe('note'); + expect(payload.entry).toBe( + "She smiled. 'Are you, Overseer? Still?'\n\n'What do you mean?'\n\n━━━\n\nFollow-up note", + ); + }); + + test('should promote an existing user book to currently reading before syncing progress', async () => { + const book = { + createdAt: 1711737600000, + metadata: { isbn: '1234567890' }, + } as Book; + const config = { progress: [25, 100] } as BookConfig; + + vi.spyOn(clientApi, 'ensureBookInLibrary').mockResolvedValue({ + editionId: 101, + pages: 100, + bookId: 202, + bookPages: 100, + userBook: { + id: 303, + status_id: 1, + user_book_reads: [], + }, + }); + const requestSpy = vi.spyOn(clientApi, 'request').mockResolvedValue({}); + + await client.pushProgress(book, config); + + const requestCalls = requestSpy.mock.calls as RequestSpyCall[]; + const calls = requestCalls.map((call) => { + return { + query: String(call[0]), + variables: call[1] as Record | undefined, + }; + }); + const firstCall = calls[0]; + const secondCall = calls[1]; + if (!firstCall || !secondCall) { + throw new Error('Expected both UpdateUserBook and InsertRead calls'); + } + + expect(firstCall.query).toContain('mutation UpdateUserBook'); + expect(firstCall.variables).toEqual({ + user_book_id: 303, + object: { status_id: 2 }, + }); + expect(secondCall.query).toContain('mutation InsertRead'); + expect(secondCall.variables).toMatchObject({ + user_book_id: 303, + progress_pages: 25, + edition_id: 101, + started_at: '2024-03-29', + }); + }); + + test('should not promote an existing user book when syncing notes only', async () => { + const book = { + hash: 'book-hash', + title: 'Test Book', + author: 'Test Author', + metadata: { isbn: '1234567890' }, + } as unknown as Book; + const config = { + progress: [25, 100], + booknotes: [ + { + id: 'note-1', + type: 'annotation', + text: 'Shared Text', + note: 'Some note', + cfi: 'epubcfi(/6/4[chap1]!/4/2,10/10)', + }, + ] as BookNote[], + } as BookConfig; + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { me: { id: 1 } } }), + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + editions: [ + { + id: 101, + pages: 100, + book: { + id: 202, + pages: 100, + user_books: [ + { + id: 303, + status_id: 3, + user_book_reads: [], + }, + ], + }, + }, + ], + }, + }), + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { insert_reading_journal: { id: 999 } } }), + }); + + const result = await client.syncBookNotes(book, config); + const fetchCalls = fetchMock.mock.calls as FetchMockCall[]; + const calls = fetchCalls.map((call) => JSON.parse(call[1]?.body ?? '{}')); + + expect(result.inserted).toBe(1); + expect( + calls.some((call: { query: string }) => call.query.includes('mutation UpdateUserBook')), + ).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/hardcover/HardcoverSyncMapStore.test.ts b/apps/readest-app/src/__tests__/services/hardcover/HardcoverSyncMapStore.test.ts new file mode 100644 index 00000000..075f2751 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/hardcover/HardcoverSyncMapStore.test.ts @@ -0,0 +1,174 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { HardcoverSyncMapStore } from '@/services/hardcover/HardcoverSyncMapStore'; +import type { AppService } from '@/types/system'; + +type MockDb = { + select: ReturnType; + execute: ReturnType; + close: ReturnType; +}; + +describe('HardcoverSyncMapStore', () => { + let mockAppService: AppService; + let mockDb: MockDb; + let store: HardcoverSyncMapStore; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock Database + mockDb = { + select: vi.fn().mockResolvedValue([]), + execute: vi.fn().mockResolvedValue({}), + close: vi.fn().mockResolvedValue({}), + }; + + // Mock AppService + mockAppService = { + openDatabase: vi.fn().mockResolvedValue(mockDb), + } as unknown as AppService; + + // Mock localStorage + const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value.toString(); + }), + key: vi.fn((index: number) => Object.keys(store)[index] || null), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + get length() { + return Object.keys(store).length; + }, + }; + })(); + + vi.stubGlobal('localStorage', localStorageMock); + vi.stubGlobal('window', { localStorage: localStorageMock }); + + store = new HardcoverSyncMapStore(mockAppService); + }); + + test('should load from localStorage on web', async () => { + const bookHash = 'test-book-hash'; + const noteId = 'note-1'; + const mapping = { + book_hash: bookHash, + note_id: noteId, + hardcover_journal_id: 123, + payload_hash: 'abc', + synced_at: Date.now(), + }; + + window.localStorage.setItem( + `hardcover-note-mapping:${bookHash}:${noteId}`, + JSON.stringify(mapping), + ); + + await store.loadForBook(bookHash); + const result = await store.getMapping(bookHash, noteId); + + expect(result).toEqual(mapping); + expect(mockAppService.openDatabase).not.toHaveBeenCalled(); + }); + + test('should load from SQLite on native', async () => { + // Force native path by removing window.localStorage + vi.stubGlobal('window', {}); + + const bookHash = 'test-book-hash'; + const noteId = 'note-1'; + const mapping = { + book_hash: bookHash, + note_id: noteId, + hardcover_journal_id: 123, + payload_hash: 'abc', + synced_at: Date.now(), + }; + + mockDb.select.mockResolvedValueOnce([mapping]); + + await store.loadForBook(bookHash); + const result = await store.getMapping(bookHash, noteId); + + expect(result).toEqual(mapping); + expect(mockAppService.openDatabase).toHaveBeenCalledWith( + 'hardcover-sync', + 'hardcover-sync.db', + 'Data', + ); + }); + + test('upsertMapping should mark as modified and flush should save to localStorage', async () => { + const bookHash = 'test-book-hash'; + const noteId = 'note-1'; + + await store.upsertMapping(bookHash, noteId, 456, 'def'); + await store.flush(); + + const stored = JSON.parse( + window.localStorage.getItem(`hardcover-note-mapping:${bookHash}:${noteId}`)!, + ); + expect(stored.hardcover_journal_id).toBe(456); + expect(stored.payload_hash).toBe('def'); + }); + + test('flush should persist mappings to SQLite on native', async () => { + vi.stubGlobal('window', {}); + + const bookHash = 'test-book-hash'; + const noteId = 'note-1'; + + await store.upsertMapping(bookHash, noteId, 456, 'def'); + await store.flush(); + + expect(mockAppService.openDatabase).toHaveBeenCalledWith( + 'hardcover-sync', + 'hardcover-sync.db', + 'Data', + ); + expect(mockDb.execute).toHaveBeenCalledTimes(1); + expect(mockDb.execute).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO hardcover_note_mappings'), + expect.arrayContaining([bookHash, noteId, 456, 'def']), + ); + }); + + test('getMappingByPayloadHash should return the latest mapping', async () => { + const bookHash = 'test-book-hash'; + const payloadHash = 'shared-payload'; + + const mapping1 = { + book_hash: bookHash, + note_id: 'note-1', + hardcover_journal_id: 101, + payload_hash: payloadHash, + synced_at: 1000, + }; + const mapping2 = { + book_hash: bookHash, + note_id: 'note-2', + hardcover_journal_id: 102, + payload_hash: payloadHash, + synced_at: 2000, + }; + + window.localStorage.setItem( + `hardcover-note-mapping:${bookHash}:note-1`, + JSON.stringify(mapping1), + ); + window.localStorage.setItem( + `hardcover-note-mapping:${bookHash}:note-2`, + JSON.stringify(mapping2), + ); + + const result = await store.getMappingByPayloadHash(bookHash, payloadHash); + expect(result!.note_id).toBe('note-2'); + }); +}); 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 ae5e849d..971f15ba 100644 --- a/apps/readest-app/src/__tests__/services/import-metahash.test.ts +++ b/apps/readest-app/src/__tests__/services/import-metahash.test.ts @@ -245,6 +245,26 @@ describe('importBook metaHash deduplication', () => { // Should create a new entry, not override existing expect(result).not.toBe(existingBook); }); + + it('should promote extracted ISBN into metadata.isbn during import', async () => { + const books: Book[] = []; + + mockPartialMD5.mockResolvedValue('new-hash-456'); + setupMockBookDoc({ + ...TEST_METADATA, + identifier: 'calibre:abc123', + altIdentifier: ['urn:isbn:9780316033664', 'mobi-asin:B004J4XGN6'], + }); + + const mockFile = new File(['new content'], 'test.epub', { type: 'application/epub+zip' }); + const result = await service.importBook(mockFile, books); + expect(result).not.toBeNull(); + if (!result) { + throw new Error('Expected importBook to return an imported book'); + } + + expect(result.metadata?.isbn).toBe('9780316033664'); + }); }); describe('importBook metaHash aggregation', () => { diff --git a/apps/readest-app/src/app/api/hardcover/graphql/route.ts b/apps/readest-app/src/app/api/hardcover/graphql/route.ts new file mode 100644 index 00000000..a27b5c50 --- /dev/null +++ b/apps/readest-app/src/app/api/hardcover/graphql/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const HARDCOVER_ENDPOINT = 'https://api.hardcover.app/v1/graphql'; + +export async function POST(request: NextRequest) { + const authorization = request.headers.get('authorization'); + if (!authorization) { + return NextResponse.json({ error: 'Missing authorization header' }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + try { + console.log('[Hardcover Proxy] forwarding request'); + + const res = await fetch(HARDCOVER_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + authorization, + }, + body: JSON.stringify(body), + }); + + const data = await res.json(); + console.log('[Hardcover Proxy] response status', res.status); + return NextResponse.json(data, { status: res.status }); + } catch (error) { + console.error('[Hardcover Proxy] fetch error:', error); + return NextResponse.json({ error: 'Failed to reach Hardcover API' }, { status: 502 }); + } +} diff --git a/apps/readest-app/src/app/reader/components/HardcoverSettings.tsx b/apps/readest-app/src/app/reader/components/HardcoverSettings.tsx new file mode 100644 index 00000000..19d29ea6 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/HardcoverSettings.tsx @@ -0,0 +1,166 @@ +import React, { useState, useEffect } from 'react'; +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useSettingsStore } from '@/store/settingsStore'; +import { eventDispatcher } from '@/utils/event'; +import { HardcoverClient, HardcoverSyncMapStore } from '@/services/hardcover'; +import Dialog from '@/components/Dialog'; + +export const setHardcoverSettingsWindowVisible = (visible: boolean) => { + const dialog = document.getElementById('hardcover_settings_window'); + if (dialog) { + const event = new CustomEvent('setHardcoverSettingsVisibility', { + detail: { visible }, + }); + dialog.dispatchEvent(event); + } +}; + +export const HardcoverSettingsWindow: React.FC = () => { + const _ = useTranslation(); + const { envConfig } = useEnv(); + const { settings, setSettings, saveSettings } = useSettingsStore(); + + const [isOpen, setIsOpen] = useState(false); + const [accessToken, setAccessToken] = useState(''); + const [isConnecting, setIsConnecting] = useState(false); + + const isConfigured = !!settings.hardcover?.accessToken; + + useEffect(() => { + const handleCustomEvent = (event: CustomEvent) => { + setIsOpen(event.detail.visible); + if (event.detail.visible) { + setAccessToken(''); + } + }; + const el = document.getElementById('hardcover_settings_window'); + el?.addEventListener('setHardcoverSettingsVisibility', handleCustomEvent as EventListener); + return () => { + el?.removeEventListener('setHardcoverSettingsVisibility', handleCustomEvent as EventListener); + }; + }, []); + + const handleConnect = async () => { + setIsConnecting(true); + try { + const appService = await envConfig.getAppService(); + const mapStore = new HardcoverSyncMapStore(appService); + const client = new HardcoverClient({ accessToken }, mapStore); + const { valid, isNetworkError } = await client.validateToken(); + if (valid) { + const newSettings = { + ...settings, + hardcover: { + enabled: true, + accessToken, + lastSyncedAt: settings.hardcover?.lastSyncedAt ?? 0, + }, + }; + setSettings(newSettings); + await saveSettings(envConfig, newSettings); + } else if (isNetworkError) { + eventDispatcher.dispatch('toast', { + message: _('Unable to connect to Hardcover. Please check your network connection.'), + type: 'error', + }); + } else { + eventDispatcher.dispatch('toast', { + message: _('Invalid Hardcover API token'), + type: 'error', + }); + } + } finally { + setIsConnecting(false); + setAccessToken(''); + } + }; + + const handleDisconnect = async () => { + const newSettings = { + ...settings, + hardcover: { enabled: false, accessToken: '', lastSyncedAt: 0 }, + }; + setSettings(newSettings); + await saveSettings(envConfig, newSettings); + eventDispatcher.dispatch('toast', { message: _('Disconnected from Hardcover'), type: 'info' }); + }; + + const handleToggleEnabled = async () => { + const newSettings = { + ...settings, + hardcover: { ...settings.hardcover, enabled: !settings.hardcover?.enabled }, + }; + setSettings(newSettings); + await saveSettings(envConfig, newSettings); + }; + + const lastSyncedAt = settings.hardcover?.lastSyncedAt ?? 0; + const lastSyncedLabel = lastSyncedAt ? new Date(lastSyncedAt).toLocaleString() : _('Never'); + + return ( + setIsOpen(false)} + title={_('Hardcover Settings')} + boxClassName='sm:!min-w-[520px] sm:h-auto' + > + {isOpen && ( +
+ {isConfigured ? ( + <> +
+

{_('Connected to Hardcover')}

+

+ {_('Last synced: {{time}}', { time: lastSyncedLabel })} +

+
+
+ {_('Sync Enabled')} + +
+ + + ) : ( + <> +

+ {_('Connect your Hardcover account to sync reading progress and notes.')} +

+

+ {_('Get your API token from hardcover.app → Settings → API.')} +

+
+ + setAccessToken(e.target.value)} + /> +
+ + + )} +
+ )} +
+ ); +}; diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index abe27f21..8c0aceac 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -26,6 +26,7 @@ import { KeyboardShortcutsHelp } from '@/components/KeyboardShortcutsHelp'; import { UpdaterWindow } from '@/components/UpdaterWindow'; import { KOSyncSettingsWindow } from './KOSyncSettings'; import { ReadwiseSettingsWindow } from './ReadwiseSettings'; +import { HardcoverSettingsWindow } from './HardcoverSettings'; import { ProofreadRulesManager } from './ProofreadRules'; import { Toast } from '@/components/Toast'; import { getLocale } from '@/utils/misc'; @@ -173,6 +174,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => { + diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 930b16af..b39c719d 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -19,6 +19,7 @@ import { useDeviceControlStore } from '@/store/deviceStore'; import { useFoliateEvents } from '../../hooks/useFoliateEvents'; import { useNotesSync } from '../../hooks/useNotesSync'; import { useReadwiseSync } from '../../hooks/useReadwiseSync'; +import { useHardcoverSync } from '../../hooks/useHardcoverSync'; import { useTextSelector } from '../../hooks/useTextSelector'; import { Point, Position, TextSelection } from '@/utils/sel'; import { getPopupPosition, getPosition, getTextFromRange } from '@/utils/sel'; @@ -53,6 +54,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { useNotesSync(bookKey); useReadwiseSync(bookKey); + useHardcoverSync(bookKey); const osPlatform = getOSPlatform(); const config = getConfig(bookKey)!; diff --git a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx index 8eff0f41..ecc5e815 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx @@ -10,6 +10,7 @@ import { useLibraryStore } from '@/store/libraryStore'; import { useSidebarStore } from '@/store/sidebarStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useSettingsStore } from '@/store/settingsStore'; +import { useBookDataStore } from '@/store/bookDataStore'; import { useParallelViewStore } from '@/store/parallelViewStore'; import { isWebAppPlatform } from '@/services/environment'; import { eventDispatcher } from '@/utils/event'; @@ -19,6 +20,7 @@ import { navigateToLogin } from '@/utils/nav'; import { saveSysSettings } from '@/helpers/settings'; import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings'; import { setReadwiseSettingsWindowVisible } from '@/app/reader/components/ReadwiseSettings'; +import { setHardcoverSettingsWindowVisible } from '@/app/reader/components/HardcoverSettings'; import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules'; import { setAboutDialogVisible } from '@/components/AboutWindow'; import useBooksManager from '../../hooks/useBooksManager'; @@ -36,6 +38,7 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) const { envConfig, appService } = useEnv(); const { user } = useAuth(); const { settings } = useSettingsStore(); + const { getConfig, setConfig, saveConfig } = useBookDataStore(); const { bookKeys, recreateViewer, getViewSettings, setViewSettings } = useReaderStore(); const { getVisibleLibrary } = useLibraryStore(); const { openParallelView } = useBooksManager(); @@ -44,6 +47,9 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) const viewSettings = getViewSettings(sideBarBookKey!); const [isSortedTOC, setIsSortedTOC] = React.useState(viewSettings?.sortedTOC || false); + const hardcoverSyncEnabledForBook = !!( + sideBarBookKey && getConfig(sideBarBookKey)?.hardcoverSyncEnabled + ); const handleParallelView = (id: string) => { openParallelView(id); @@ -107,6 +113,42 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) eventDispatcher.dispatch('readwise-push-all', { bookKey: sideBarBookKey }); setIsDropdownOpen?.(false); }; + const showHardcoverSettingsWindow = () => { + setHardcoverSettingsWindowVisible(true); + setIsDropdownOpen?.(false); + }; + const handlePushHardcoverNotes = () => { + eventDispatcher.dispatch('hardcover-push-notes', { bookKey: sideBarBookKey }); + setIsDropdownOpen?.(false); + }; + const handlePushHardcoverProgress = () => { + eventDispatcher.dispatch('hardcover-push-progress', { bookKey: sideBarBookKey }); + setIsDropdownOpen?.(false); + }; + const handleToggleHardcoverBookSync = async () => { + if (!sideBarBookKey) return; + const config = getConfig(sideBarBookKey); + if (!config) return; + + const nextValue = !config.hardcoverSyncEnabled; + const updatedConfig = { + ...config, + hardcoverSyncEnabled: nextValue, + updatedAt: Date.now(), + }; + setConfig(sideBarBookKey, { + hardcoverSyncEnabled: nextValue, + updatedAt: updatedConfig.updatedAt, + }); + await saveConfig(envConfig, sideBarBookKey, updatedConfig, settings); + eventDispatcher.dispatch('toast', { + message: nextValue + ? _('Hardcover sync enabled for this book') + : _('Hardcover sync disabled for this book'), + type: 'info', + }); + setIsDropdownOpen?.(false); + }; const toggleDiscordPresence = () => { const discordRichPresenceEnabled = !settings.discordRichPresenceEnabled; saveSysSettings(envConfig, 'discordRichPresenceEnabled', discordRichPresenceEnabled); @@ -182,6 +224,23 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) ) : ( )} + {settings.hardcover.enabled ? ( + +
    + + + + +
+
+ ) : ( + + )} {appService?.isDesktopApp && ( <> diff --git a/apps/readest-app/src/app/reader/hooks/useHardcoverSync.ts b/apps/readest-app/src/app/reader/hooks/useHardcoverSync.ts new file mode 100644 index 00000000..f7d373cf --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useHardcoverSync.ts @@ -0,0 +1,158 @@ +import { useCallback, useEffect } from 'react'; +import { useEnv } from '@/context/EnvContext'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useTranslation } from '@/hooks/useTranslation'; +import { eventDispatcher } from '@/utils/event'; +import { HardcoverClient, HardcoverSyncMapStore } from '@/services/hardcover'; +import { BookNote } from '@/types/book'; + +export const useHardcoverSync = (bookKey: string) => { + const _ = useTranslation(); + const { envConfig } = useEnv(); + const { getConfig, getBookData } = useBookDataStore(); + + const updateLastSyncedAt = useCallback( + async (timestamp: number) => { + const { settings, setSettings, saveSettings } = useSettingsStore.getState(); + const newSettings = { + ...settings, + hardcover: { ...settings.hardcover, lastSyncedAt: timestamp }, + }; + setSettings(newSettings); + await saveSettings(envConfig, newSettings); + }, + [envConfig], + ); + + const getClient = useCallback(async () => { + const { settings } = useSettingsStore.getState(); + if (!settings.hardcover?.enabled || !settings.hardcover?.accessToken) { + return null; + } + const appService = await envConfig.getAppService(); + const mapStore = new HardcoverSyncMapStore(appService); + return new HardcoverClient(settings.hardcover, mapStore); + }, [envConfig]); + + const pushNotes = useCallback(async () => { + const config = getConfig(bookKey); + const book = getBookData(bookKey)?.book; + if (!config || !book) return; + if (!config.hardcoverSyncEnabled) { + eventDispatcher.dispatch('toast', { + message: _('Enable Hardcover sync for this book first.'), + type: 'info', + }); + return; + } + + const eligibleNotes = (config.booknotes ?? []).filter( + (note: BookNote) => + (note.type === 'annotation' || note.type === 'excerpt') && !note.deletedAt, + ); + + if (eligibleNotes.length === 0) { + eventDispatcher.dispatch('toast', { + message: _('No annotations or excerpts to sync for this book.'), + type: 'info', + }); + return; + } + + const client = await getClient(); + if (!client) { + eventDispatcher.dispatch('toast', { + message: _('Configure Hardcover in Settings first.'), + type: 'info', + }); + return; + } + + try { + const result = await client.syncBookNotes(book, config); + + await updateLastSyncedAt(Date.now()); + eventDispatcher.dispatch('toast', { + message: + result.inserted === 0 && result.updated === 0 + ? _('No new Hardcover note changes to sync.') + : _('Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged', { + inserted: result.inserted, + updated: result.updated, + skipped: result.skipped, + }), + type: result.inserted === 0 && result.updated === 0 ? 'info' : 'success', + }); + } catch (error) { + console.error('Hardcover notes sync failed:', error); + eventDispatcher.dispatch('toast', { + message: _('Hardcover notes sync failed: {{error}}', { + error: (error as Error).message, + }), + type: 'error', + }); + } + }, [_, bookKey, getBookData, getClient, getConfig, updateLastSyncedAt]); + + const pushProgress = useCallback(async () => { + const config = getConfig(bookKey); + const book = getBookData(bookKey)?.book; + if (!config || !book) return; + if (!config.hardcoverSyncEnabled) { + eventDispatcher.dispatch('toast', { + message: _('Enable Hardcover sync for this book first.'), + type: 'info', + }); + return; + } + + const client = await getClient(); + if (!client) { + eventDispatcher.dispatch('toast', { + message: _('Configure Hardcover in Settings first.'), + type: 'info', + }); + return; + } + + try { + await client.pushProgress(book, config); + await updateLastSyncedAt(Date.now()); + eventDispatcher.dispatch('toast', { + message: _('Reading progress synced to Hardcover'), + type: 'success', + }); + } catch (error) { + console.error('Hardcover progress sync failed:', error); + eventDispatcher.dispatch('toast', { + message: _('Hardcover progress sync failed: {{error}}', { + error: (error as Error).message, + }), + type: 'error', + }); + } + }, [_, bookKey, getBookData, getClient, getConfig, updateLastSyncedAt]); + + useEffect(() => { + const handlePushNotes = async (event: CustomEvent) => { + if (event.detail.bookKey !== bookKey) return; + await pushNotes(); + }; + + const handlePushProgress = async (event: CustomEvent) => { + if (event.detail.bookKey !== bookKey) return; + await pushProgress(); + }; + + eventDispatcher.on('hardcover-push-notes', handlePushNotes); + eventDispatcher.on('hardcover-push-progress', handlePushProgress); + + return () => { + eventDispatcher.off('hardcover-push-notes', handlePushNotes); + eventDispatcher.off('hardcover-push-progress', handlePushProgress); + }; + }, [bookKey, pushNotes, pushProgress]); + + return { pushNotes, pushProgress }; +}; diff --git a/apps/readest-app/src/components/metadata/BookDetailEdit.tsx b/apps/readest-app/src/components/metadata/BookDetailEdit.tsx index e13c5dee..fda3f3e2 100644 --- a/apps/readest-app/src/components/metadata/BookDetailEdit.tsx +++ b/apps/readest-app/src/components/metadata/BookDetailEdit.tsx @@ -101,6 +101,12 @@ const BookDetailEdit: React.FC = ({ value: String(metadata.seriesTotal || ''), placeholder: _('Enter total books in series'), }, + { + field: 'isbn', + label: _('ISBN'), + value: metadata.isbn || '', + placeholder: '9780123456789', + }, { field: 'publisher', label: _('Publisher'), @@ -125,7 +131,7 @@ const BookDetailEdit: React.FC = ({ field: 'identifier', label: _('Identifier'), value: metadata.identifier || '', - placeholder: '978-0123456789', + placeholder: _('Keep existing source identifier'), }, ]; const metadataFullwidthFields = [ diff --git a/apps/readest-app/src/components/metadata/SourceSelector.tsx b/apps/readest-app/src/components/metadata/SourceSelector.tsx index 86416c88..3563238a 100644 --- a/apps/readest-app/src/components/metadata/SourceSelector.tsx +++ b/apps/readest-app/src/components/metadata/SourceSelector.tsx @@ -89,8 +89,10 @@ const SourceSelector: React.FC = ({ sources, isOpen, onSele {source.data.publisher ? `${source.data.publisher} • ` : ''} {source.data.published} - {source.data.identifier && ( -
Identifier: {source.data.identifier}
+ {(source.data.isbn || source.data.identifier) && ( +
+ ISBN: {source.data.isbn || source.data.identifier} +
)} {source.data.description && (
diff --git a/apps/readest-app/src/components/metadata/useMetadataEdit.ts b/apps/readest-app/src/components/metadata/useMetadataEdit.ts index c6178a96..4400b507 100644 --- a/apps/readest-app/src/components/metadata/useMetadataEdit.ts +++ b/apps/readest-app/src/components/metadata/useMetadataEdit.ts @@ -24,6 +24,7 @@ export const useMetadataEdit = (metadata: BookMetadata | null) => { const lockableFields = [ 'title', 'author', + 'isbn', 'publisher', 'published', 'language', @@ -130,6 +131,17 @@ export const useMetadataEdit = (metadata: BookMetadata | null) => { } } break; + + case 'isbn': + if (value.trim()) { + validationResult = validateISBN(value); + if (!validationResult.isValid) { + console.warn(`Invalid ISBN for field ${field}:`, validationResult.error); + setFieldErrors((prev) => ({ ...prev, [field]: validationResult.error || '' })); + return false; + } + } + break; } setFieldErrors((prev) => { @@ -167,11 +179,11 @@ export const useMetadataEdit = (metadata: BookMetadata | null) => { const handleAutoRetrieve = async () => { setSearchLoading(true); try { - const isbnValidation = validateISBN(editedMeta.identifier || ''); + const isbnValidation = validateISBN(editedMeta.isbn || ''); const results = await searchMetadata({ title: formatTitle(editedMeta.title), author: formatAuthors(editedMeta.author), - isbn: isbnValidation.isValid ? editedMeta.identifier : undefined, + isbn: isbnValidation.isValid ? editedMeta.isbn : undefined, language: getPrimaryLanguage(editedMeta.language), }); const metadataSources = results.map((result) => ({ @@ -198,6 +210,18 @@ export const useMetadataEdit = (metadata: BookMetadata | null) => { return; } switch (key) { + case 'identifier': { + const candidate = String(value); + const isbnValidation = validateISBN(candidate); + if (!lockedFields['isbn'] && isbnValidation.isValid) { + newMeta['isbn'] = candidate; + newSources['isbn'] = `${selectedSource.sourceName}-${selectedSource.confidence}`; + } else { + newMeta[key] = value; + newSources[key] = `${selectedSource.sourceName}-${selectedSource.confidence}`; + } + return; + } default: newMeta[key] = value; } diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts index 7179745c..aa011598 100644 --- a/apps/readest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -47,6 +47,7 @@ export type BookMetadata = { description?: string; subject?: string | string[] | Contributor; identifier?: string; + isbn?: string; altIdentifier?: string | string[] | Identifier; belongsTo?: { collection?: Array | Collection; diff --git a/apps/readest-app/src/services/bookService.ts b/apps/readest-app/src/services/bookService.ts index 2baca3bf..95c9d8cd 100644 --- a/apps/readest-app/src/services/bookService.ts +++ b/apps/readest-app/src/services/bookService.ts @@ -28,6 +28,7 @@ import { deserializeConfig, serializeConfig } from '@/utils/serializer'; import { ClosableFile } from '@/utils/file'; import { TxtToEpubConverter } from '@/utils/txt'; import { svg2png } from '@/utils/svg'; +import { normalizeMetadataIsbn } from '@/utils/isbn'; import { BookFileNotFoundError } from './errors'; import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; @@ -228,6 +229,7 @@ export async function importBook( if (!loadedBook) { throw new Error('Unsupported or corrupted book file'); } + normalizeMetadataIsbn(loadedBook.metadata); const metadataTitle = formatTitle(loadedBook.metadata.title); if (!metadataTitle || !metadataTitle.trim() || metadataTitle === filename) { loadedBook.metadata.title = getBaseFilename(filename); diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index f8c54942..3da40339 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -16,6 +16,7 @@ import { ViewSettings, } from '@/types/book'; import { + HardcoverSettings, KOSyncSettings, LibraryGroupByType, LibrarySortByType, @@ -73,6 +74,12 @@ export const DEFAULT_READWISE_SETTINGS = { lastSyncedAt: 0, } as ReadwiseSettings; +export const DEFAULT_HARDCOVER_SETTINGS = { + enabled: false, + accessToken: '', + lastSyncedAt: 0, +} as HardcoverSettings; + export const DEFAULT_SYSTEM_SETTINGS: Partial = { keepLogin: false, autoUpload: true, @@ -103,6 +110,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = { kosync: DEFAULT_KOSYNC_SETTINGS, readwise: DEFAULT_READWISE_SETTINGS, + hardcover: DEFAULT_HARDCOVER_SETTINGS, aiSettings: DEFAULT_AI_SETTINGS, lastSyncedAtBooks: 0, diff --git a/apps/readest-app/src/services/database/migrations/index.ts b/apps/readest-app/src/services/database/migrations/index.ts index 9aeab335..d4cf0ee3 100644 --- a/apps/readest-app/src/services/database/migrations/index.ts +++ b/apps/readest-app/src/services/database/migrations/index.ts @@ -13,22 +13,24 @@ import { MigrationEntry, SchemaType } from '../migrate'; * 2. Add a new key here with its migration array. */ const migrations: Record = { - // Example schema — replace with real migrations when ready: - // - // library: [ - // { - // name: '2026030601_initial_schema', - // sql: ` - // CREATE TABLE IF NOT EXISTS books ( - // id INTEGER PRIMARY KEY AUTOINCREMENT, - // title TEXT NOT NULL, - // author TEXT, - // path TEXT NOT NULL UNIQUE, - // created_at DATETIME DEFAULT CURRENT_TIMESTAMP - // ); - // `, - // }, - // ], + 'hardcover-sync': [ + { + name: '2026032901_hardcover_note_mappings', + sql: ` + CREATE TABLE IF NOT EXISTS hardcover_note_mappings ( + book_hash TEXT NOT NULL, + note_id TEXT NOT NULL, + hardcover_journal_id INTEGER NOT NULL, + payload_hash TEXT NOT NULL, + synced_at INTEGER NOT NULL, + PRIMARY KEY (book_hash, note_id) + ); + + CREATE INDEX IF NOT EXISTS idx_hardcover_note_mappings_synced_at + ON hardcover_note_mappings (synced_at); + `, + }, + ], }; export function getMigrations(schema: SchemaType): MigrationEntry[] { diff --git a/apps/readest-app/src/services/hardcover/HardcoverClient.ts b/apps/readest-app/src/services/hardcover/HardcoverClient.ts new file mode 100644 index 00000000..4e580205 --- /dev/null +++ b/apps/readest-app/src/services/hardcover/HardcoverClient.ts @@ -0,0 +1,559 @@ +import { Book, BookConfig, BookNote } from '@/types/book'; +import { getContentMd5 } from '@/utils/misc'; +import { HardcoverSyncMapStore } from './HardcoverSyncMapStore'; +import { + QUERY_GET_USER_ID, + QUERY_SEARCH_BOOK, + QUERY_GET_EDITION, + MUTATION_INSERT_USER_BOOK, + MUTATION_UPDATE_USER_BOOK, + MUTATION_INSERT_READ, + MUTATION_UPDATE_READ, + MUTATION_INSERT_JOURNAL, + MUTATION_UPDATE_JOURNAL, +} from './hardcover-graphql'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +type HardcoverSettingsLike = { + accessToken: string; +}; + +type BookContext = { + editionId: number; + pages: number | null; + bookId: number; + bookPages: number | null; + userBook: { + id: number; + status_id: number; + user_book_reads: Array<{ id: number; started_at: string | null }>; + } | null; +}; + +const isTauriEnv = () => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; + +export class HardcoverClient { + private minRequestIntervalMs = 1150; + private directEndpoint = 'https://api.hardcover.app/v1/graphql'; + private proxyEndpoint = '/api/hardcover/graphql'; + private token: string; + private mapStore: HardcoverSyncMapStore; + private userId: number | null = null; + private lastRequestTime = 0; + private requestQueue: Promise = Promise.resolve(); + + constructor(settings: HardcoverSettingsLike, mapStore: HardcoverSyncMapStore) { + // Normalize token: Hardcover expects "Bearer "; accept both formats + const raw = settings.accessToken.trim(); + this.token = raw.startsWith('Bearer ') ? raw : `Bearer ${raw}`; + this.mapStore = mapStore; + } + + private get endpoint() { + return isTauriEnv() ? this.directEndpoint : this.proxyEndpoint; + } + + private formatDate(date: Date): string { + return date.toISOString().replace(/\.\d+/, '').replace('Z', '+00:00'); + } + + private formatDay(date: Date): string { + return date.toISOString().slice(0, 10); + } + + private normalizeNoteDedupCfi(cfi: string | null | undefined): string { + return cfi ? cfi.replace(/:\d+/g, '') : ''; + } + + private getNoteDedupKey(note: BookNote): string { + const text = note.text?.trim() || ''; + const normalizedCfi = this.normalizeNoteDedupCfi(note.cfi); + return `${normalizedCfi}|${text}`; + } + + private async throttleRequest() { + const queued = this.requestQueue + .catch(() => undefined) + .then(async () => { + const now = Date.now(); + const elapsed = now - this.lastRequestTime; + if (elapsed < this.minRequestIntervalMs) { + await sleep(this.minRequestIntervalMs - elapsed); + } + this.lastRequestTime = Date.now(); + }); + + this.requestQueue = queued; + await queued; + } + + private async request( + query: string, + variables: TVariables, + retries = 3, + backoffMs = 2000, + ): Promise { + await this.throttleRequest(); + + const res = await fetch(this.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + authorization: this.token, + }, + body: JSON.stringify({ query, variables }), + }); + + if (res.status === 429) { + if (retries > 0) { + console.warn(`[Hardcover] 429 Rate Limit hit. Retrying in ${backoffMs}ms...`); + await sleep(backoffMs); + return this.request(query, variables, retries - 1, backoffMs * 2); + } + throw new Error('Hardcover Rate Limit (429) Exceeded and exhausted retries'); + } + + if (!res.ok) { + throw new Error(`Hardcover API Error: ${res.status} ${res.statusText}`); + } + + const json = await res.json(); + if (json.errors) { + throw new Error(`GraphQL Errors: ${JSON.stringify(json.errors)}`); + } + + return json.data as TData; + } + + async validateToken(): Promise<{ valid: boolean; isNetworkError?: boolean }> { + try { + await this.authenticate(); + return { valid: true }; + } catch (error) { + const msg = String(error instanceof Error ? error.message : error); + if (/Failed to fetch|NetworkError|network/i.test(msg)) { + return { valid: false, isNetworkError: true }; + } + return { valid: false }; + } + } + + private async authenticate() { + if (this.userId) return; + const data = await this.request< + Record, + { me: { id: number } | Array<{ id: number }> } + >(QUERY_GET_USER_ID, {}); + const me = Array.isArray(data.me) ? data.me[0] : data.me; + if (!me?.id) { + throw new Error('Invalid Hardcover token: user ID not found'); + } + this.userId = me.id; + } + + private normalizeIdentifier(identifier: string): string { + if (identifier.includes('urn:')) { + return identifier.match(/[^:]+$/)?.[0] || ''; + } + if (identifier.includes(':')) { + return identifier.match(/^[^:]+:(.+)$/)?.[1] || ''; + } + return identifier; + } + + private extractISBN(book: Book): string | null { + const metadata = book.metadata; + if (!metadata) return null; + + if (metadata.isbn) { + const normalizedIsbn = metadata.isbn.replace(/[-\s]/g, ''); + if (/^\d{10}(\d{3})?$/.test(normalizedIsbn)) { + return normalizedIsbn; + } + } + + const identifiers: Array<{ scheme?: string; value: string }> = []; + const pushMaybe = (value?: string, scheme?: string) => { + if (!value) return; + identifiers.push({ scheme, value }); + }; + + const collect = (raw: unknown) => { + if (!raw) return; + if (typeof raw === 'string') { + pushMaybe(raw); + } else if (Array.isArray(raw)) { + for (const item of raw) { + if (typeof item === 'string') { + pushMaybe(item); + } else if (item && typeof item === 'object') { + const obj = item as { scheme?: string; value?: string }; + pushMaybe(obj.value, obj.scheme); + } + } + } else if (raw && typeof raw === 'object') { + const obj = raw as { scheme?: string; value?: string }; + pushMaybe(obj.value, obj.scheme); + } + }; + + collect(metadata.identifier); + collect(metadata.altIdentifier); + + for (const identifier of identifiers) { + const scheme = (identifier.scheme || '').toLowerCase(); + const normalized = this.normalizeIdentifier(identifier.value).replace(/[-\s]/g, ''); + const looksLikeISBN = /^\d{10}(\d{3})?$/.test(normalized); + if (scheme === 'isbn' || identifier.value.toLowerCase().includes('isbn') || looksLikeISBN) { + return normalized; + } + } + + return null; + } + + private async searchBookByTitle(title: string, author: string): Promise { + await this.authenticate(); + const query = `${title} ${author}`.trim(); + const data = await this.request<{ query: string }, { search?: { results?: unknown } }>( + QUERY_SEARCH_BOOK, + { query }, + ); + + const rawResults = data.search?.results; + let hits: unknown[] = []; + if (typeof rawResults === 'string') { + try { + hits = JSON.parse(rawResults); + } catch { + hits = []; + } + } else if ( + rawResults && + typeof rawResults === 'object' && + 'hits' in rawResults && + Array.isArray((rawResults as { hits?: unknown[] }).hits) + ) { + hits = (rawResults as { hits: unknown[] }).hits; + } else if (Array.isArray(rawResults)) { + hits = rawResults; + } + + const hit = (hits[0] || {}) as { + id?: number; + pages?: number; + featured_edition_id?: number; + document?: { id?: number; pages?: number; featured_edition_id?: number }; + }; + + const bookId = hit.id ?? hit.document?.id; + if (!bookId) return null; + + const editionId = hit.featured_edition_id ?? hit.document?.featured_edition_id ?? bookId; + const pages = hit.pages ?? hit.document?.pages ?? null; + + return { + editionId, + pages, + bookId, + bookPages: pages, + userBook: null, + }; + } + + private async fetchBookContext(book: Book): Promise { + await this.authenticate(); + const isbn = this.extractISBN(book); + + if (isbn && this.userId) { + const data = await this.request< + { isbn: string[]; user_id: number }, + { + editions?: Array<{ + id: number; + pages: number | null; + book: { + id: number; + pages: number | null; + user_books?: Array<{ + id: number; + status_id: number; + user_book_reads?: Array<{ id: number; started_at: string | null }>; + }>; + }; + }>; + } + >(QUERY_GET_EDITION, { + isbn: [isbn], + user_id: this.userId, + }); + + const edition = data.editions?.[0]; + if (edition) { + const userBook = edition.book.user_books?.[0]; + return { + editionId: edition.id, + pages: edition.pages, + bookId: edition.book.id, + bookPages: edition.book.pages, + userBook: userBook + ? { + ...userBook, + user_book_reads: userBook.user_book_reads ?? [], + } + : null, + }; + } + } + + if (book.title && book.author) { + return this.searchBookByTitle(book.title, book.author); + } + + return null; + } + + private async updateUserBookStatus(context: BookContext, statusId: number): Promise { + if (!context.userBook || context.userBook.status_id === statusId) return; + + await this.request(MUTATION_UPDATE_USER_BOOK, { + user_book_id: context.userBook.id, + object: { status_id: statusId }, + }); + + context.userBook.status_id = statusId; + } + + private async ensureBookInLibrary(book: Book, isReading = true): Promise { + const context = await this.fetchBookContext(book); + if (!context) return null; + + if (context.userBook) return context; + + const data = await this.request< + { object: { book_id: number; edition_id: number; status_id: number } }, + { insert_user_book: { user_book: { id: number } } } + >(MUTATION_INSERT_USER_BOOK, { + object: { + book_id: context.bookId, + edition_id: context.editionId, + status_id: isReading ? 2 : 1, + }, + }); + + return { + ...context, + userBook: { + id: data.insert_user_book.user_book.id, + status_id: isReading ? 2 : 1, + user_book_reads: [], + }, + }; + } + + async pushProgress(book: Book, config: BookConfig): Promise { + const context = await this.ensureBookInLibrary(book, true); + if (!context?.userBook) return; + + await this.updateUserBookStatus(context, 2); + + const current = config.progress?.[0] ?? book.progress?.[0] ?? 0; + const total = + config.progress?.[1] ?? book.progress?.[1] ?? context.pages ?? context.bookPages ?? 0; + if (total <= 0) return; + + const pagesRead = Math.min(Math.max(current, 0), total); + const percent = total > 0 ? (pagesRead / total) * 100 : 0; + const activeRead = context.userBook.user_book_reads?.[0]; + const startedAt = this.formatDay(new Date(book.createdAt || Date.now())); + + if (activeRead?.id) { + await this.request(MUTATION_UPDATE_READ, { + id: activeRead.id, + progress_pages: pagesRead, + edition_id: context.editionId, + started_at: activeRead.started_at || startedAt, + }); + } else { + await this.request(MUTATION_INSERT_READ, { + user_book_id: context.userBook.id, + edition_id: context.editionId, + progress_pages: pagesRead, + started_at: startedAt, + }); + } + + if (percent >= 100) { + await this.updateUserBookStatus(context, 3); + } + } + + private buildJournalPayload(note: BookNote, config: BookConfig, context: BookContext) { + const totalPages = config.progress?.[1] ?? context.pages ?? context.bookPages ?? 0; + const fallbackPage = config.progress?.[0] ?? 0; + const page = note.page && note.page > 0 ? note.page : fallbackPage; + const boundedPage = Math.max(0, Math.min(page, totalPages || page)); + const percent = totalPages > 0 ? (boundedPage / totalPages) * 100 : 0; + + let entry = ''; + if (note.text?.trim()) { + entry += note.text.trim(); + } + if (note.note) { + if (entry) { + entry += '\n\n━━━\n\n'; + } + entry += note.note; + } + + const finalEntry = entry.trim(); + + return { + event: note.note ? 'note' : 'quote', + entry: finalEntry, + page: boundedPage, + possible: totalPages || Math.max(boundedPage, 1), + percent, + action_at: this.formatDate(new Date(note.updatedAt || note.createdAt || Date.now())), + privacy_setting_id: 3, + }; + } + + private async insertJournal( + context: BookContext, + payload: Record, + ): Promise { + const data = await this.request< + Record, + { insert_reading_journal?: { id?: number; errors?: unknown } } + >(MUTATION_INSERT_JOURNAL, { + book_id: context.bookId, + edition_id: context.editionId, + ...payload, + }); + + const id = data.insert_reading_journal?.id; + if (!id) { + throw new Error('Hardcover insert_reading_journal returned no id'); + } + return id; + } + + private async updateJournal(journalId: number, payload: Record): Promise { + await this.request(MUTATION_UPDATE_JOURNAL, { + id: journalId, + ...payload, + }); + } + + private isMissingJournalError(error: unknown): boolean { + const message = String(error instanceof Error ? error.message : error).toLowerCase(); + return ( + message.includes('not found') || + message.includes('does not exist') || + message.includes('null value') + ); + } + + async syncBookNotes( + book: Book, + config: BookConfig, + ): Promise<{ inserted: number; updated: number; skipped: number }> { + const context = await this.ensureBookInLibrary(book, true); + if (!context) { + throw new Error('Unable to resolve this book in Hardcover'); + } + + const rawNotes = (config.booknotes ?? []).filter( + (note) => (note.type === 'annotation' || note.type === 'excerpt') && !note.deletedAt, + ); + + // Readest can keep both an excerpt (quote) and an annotation (quote + note) + // for the same highlight. We normalize EPUB CFI range offsets so the same + // range with small trailing offset differences still dedupes, while keeping + // the rest of the range path intact. + const annotationWithNoteKeys = new Set(); + for (const note of rawNotes) { + if (note.type === 'annotation' && note.note?.trim()) { + annotationWithNoteKeys.add(this.getNoteDedupKey(note)); + } + } + + const notes = rawNotes.filter((note) => { + const key = this.getNoteDedupKey(note); + if (!annotationWithNoteKeys.has(key)) return true; + + // When a note-bearing annotation exists for the same location and text, + // suppress quote-like duplicates from both excerpt rows and + // empty-note annotation rows. + if (note.type === 'excerpt') return false; + if (note.type === 'annotation' && !note.note?.trim()) return false; + + return true; + }); + + let inserted = 0; + let updated = 0; + let skipped = 0; + + try { + for (const note of notes) { + const payload = this.buildJournalPayload(note, config, context); + if (!payload.entry) { + skipped += 1; + continue; + } + + const payloadHash = getContentMd5(payload); + const existing = await this.mapStore.getMapping(book.hash, note.id); + + if (!existing) { + const samePayload = await this.mapStore.getMappingByPayloadHash(book.hash, payloadHash); + if (samePayload) { + await this.mapStore.upsertMapping( + book.hash, + note.id, + samePayload.hardcover_journal_id, + payloadHash, + ); + skipped += 1; + continue; + } + + const journalId = await this.insertJournal(context, payload); + await this.mapStore.upsertMapping(book.hash, note.id, journalId, payloadHash); + inserted += 1; + continue; + } + + if (existing.payload_hash === payloadHash) { + skipped += 1; + continue; + } + + try { + await this.updateJournal(existing.hardcover_journal_id, payload); + await this.mapStore.upsertMapping( + book.hash, + note.id, + existing.hardcover_journal_id, + payloadHash, + ); + updated += 1; + } catch (error) { + if (!this.isMissingJournalError(error)) { + throw error; + } + const journalId = await this.insertJournal(context, payload); + await this.mapStore.upsertMapping(book.hash, note.id, journalId, payloadHash); + inserted += 1; + } + } + } finally { + await this.mapStore.flush(); + } + + return { inserted, updated, skipped }; + } +} diff --git a/apps/readest-app/src/services/hardcover/HardcoverSyncMapStore.ts b/apps/readest-app/src/services/hardcover/HardcoverSyncMapStore.ts new file mode 100644 index 00000000..64c1f523 --- /dev/null +++ b/apps/readest-app/src/services/hardcover/HardcoverSyncMapStore.ts @@ -0,0 +1,157 @@ +import { AppService } from '@/types/system'; + +type HardcoverSyncMapRow = { + book_hash: string; + note_id: string; + hardcover_journal_id: number; + payload_hash: string; + synced_at: number; +}; + +const DB_SCHEMA = 'hardcover-sync'; +const DB_PATH = 'hardcover-sync.db'; +const STORAGE_PREFIX = 'hardcover-note-mapping'; + +export class HardcoverSyncMapStore { + private appService: AppService; + private loadedBookHash: string | null = null; + private mappings: Map = new Map(); + private modified: boolean = false; + + constructor(appService: AppService) { + this.appService = appService; + } + + private isWebStorageAvailable(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; + } + + private getStorageKey(bookHash: string, noteId: string): string { + return `${STORAGE_PREFIX}:${bookHash}:${noteId}`; + } + + private async withDb(fn: (db: Awaited>) => Promise) { + const db = await this.appService.openDatabase(DB_SCHEMA, DB_PATH, 'Data'); + try { + return await fn(db); + } finally { + await db.close(); + } + } + + async loadForBook(bookHash: string): Promise { + this.loadedBookHash = bookHash; + this.mappings.clear(); + this.modified = false; + + if (this.isWebStorageAvailable()) { + try { + const prefix = `${STORAGE_PREFIX}:${bookHash}:`; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key && key.startsWith(prefix)) { + const raw = window.localStorage.getItem(key); + if (raw) { + const row = JSON.parse(raw) as HardcoverSyncMapRow; + this.mappings.set(row.note_id, row); + } + } + } + } catch (error) { + console.error('Failed to read Hardcover note mapping from localStorage:', error); + } + return; + } + + await this.withDb(async (db) => { + const rows = await db.select( + `SELECT book_hash, note_id, hardcover_journal_id, payload_hash, synced_at + FROM hardcover_note_mappings + WHERE book_hash = ?`, + [bookHash], + ); + for (const row of rows) { + this.mappings.set(row.note_id, row); + } + }); + } + + async flush(): Promise { + if (!this.modified || !this.loadedBookHash) return; + + if (this.isWebStorageAvailable()) { + try { + for (const row of this.mappings.values()) { + window.localStorage.setItem( + this.getStorageKey(row.book_hash, row.note_id), + JSON.stringify(row), + ); + } + this.modified = false; + } catch (error) { + console.error('Failed to write Hardcover note mapping to localStorage:', error); + } + return; + } + + await this.withDb(async (db) => { + // Execute inserts sequentially but within a single DB connection + for (const row of this.mappings.values()) { + await db.execute( + `INSERT INTO hardcover_note_mappings + (book_hash, note_id, hardcover_journal_id, payload_hash, synced_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(book_hash, note_id) + DO UPDATE SET + hardcover_journal_id = excluded.hardcover_journal_id, + payload_hash = excluded.payload_hash, + synced_at = excluded.synced_at`, + [row.book_hash, row.note_id, row.hardcover_journal_id, row.payload_hash, row.synced_at], + ); + } + }); + this.modified = false; + } + + async getMapping(bookHash: string, noteId: string): Promise { + if (this.loadedBookHash !== bookHash) { + await this.loadForBook(bookHash); + } + return this.mappings.get(noteId) || null; + } + + async getMappingByPayloadHash( + bookHash: string, + payloadHash: string, + ): Promise { + if (this.loadedBookHash !== bookHash) { + await this.loadForBook(bookHash); + } + let best: HardcoverSyncMapRow | null = null; + for (const row of this.mappings.values()) { + if (row.payload_hash === payloadHash) { + if (!best || row.synced_at > best.synced_at) best = row; + } + } + return best; + } + + async upsertMapping( + bookHash: string, + noteId: string, + journalId: number, + payloadHash: string, + ): Promise { + if (this.loadedBookHash !== bookHash) { + await this.loadForBook(bookHash); + } + this.mappings.set(noteId, { + book_hash: bookHash, + note_id: noteId, + hardcover_journal_id: journalId, + payload_hash: payloadHash, + synced_at: Date.now(), + }); + this.modified = true; + } +} diff --git a/apps/readest-app/src/services/hardcover/hardcover-graphql.ts b/apps/readest-app/src/services/hardcover/hardcover-graphql.ts new file mode 100644 index 00000000..f6460260 --- /dev/null +++ b/apps/readest-app/src/services/hardcover/hardcover-graphql.ts @@ -0,0 +1,135 @@ +export const QUERY_GET_USER_ID = ` +query GetUserId { + me { + id + username + } +} +`; + +export const QUERY_SEARCH_BOOK = ` +query SearchBook($query: String!) { + search(query: $query, query_type: "Book", per_page: 1) { + results + } +} +`; + +export const QUERY_GET_EDITION = ` +query GetEdition($isbn: [String!]!, $user_id: Int!) { + editions( + where: { _or: [ { asin: { _in: $isbn } }, { isbn_10: { _in: $isbn } }, { isbn_13: { _in: $isbn } } ] } + limit: 1 + ) { + id + pages + book { + id + pages + user_books(where: { user_id: { _eq: $user_id } }) { + id + status_id + user_book_reads( + where: { finished_at: { _is_null: true } } + order_by: { started_at: desc } + limit: 1 + ) { + id + started_at + edition { id pages } + } + } + } + } +} +`; + +export const MUTATION_INSERT_USER_BOOK = ` +mutation InsertUserBook($object: UserBookCreateInput!) { + insert_user_book(object: $object) { + error + user_book { id } + } +} +`; + +export const MUTATION_UPDATE_USER_BOOK = ` +mutation UpdateUserBook($user_book_id: Int!, $object: UserBookUpdateInput!) { + update_user_book(id: $user_book_id, object: $object) { id error } +} +`; + +export const MUTATION_INSERT_READ = ` +mutation InsertRead($user_book_id: Int!, $edition_id: Int!, $progress_pages: Int!, $started_at: date!) { + insert_user_book_read( + user_book_id: $user_book_id + user_book_read: { + edition_id: $edition_id + progress_pages: $progress_pages + started_at: $started_at + } + ) { id error } +} +`; + +export const MUTATION_UPDATE_READ = ` +mutation UpdateRead($id: Int!, $progress_pages: Int!, $edition_id: Int!, $started_at: date!) { + update_user_book_read( + id: $id + object: { + edition_id: $edition_id + started_at: $started_at + progress_pages: $progress_pages + } + ) { id error } +} +`; + +export const MUTATION_INSERT_JOURNAL = ` +mutation InsertReadingJournal( + $book_id: Int!, $edition_id: Int!, $event: String!, $entry: String!, + $action_at: date, $page: Int!, $possible: Int!, $percent: Float!, $privacy_setting_id: Int! +) { + insert_reading_journal( + object: { + book_id: $book_id + edition_id: $edition_id + event: $event + privacy_setting_id: $privacy_setting_id + entry: $entry + tags: { category: "", spoiler: false, tag: "" } + action_at: $action_at + metadata: { + position: { type: pages, value: $page, possible: $possible, percent: $percent } + } + } + ) { errors id } +} +`; + +export const MUTATION_UPDATE_JOURNAL = ` +mutation UpdateReadingJournal( + $id: Int!, + $event: String!, + $entry: String!, + $action_at: date, + $page: Int!, + $possible: Int!, + $percent: Float!, + $privacy_setting_id: Int! +) { + update_reading_journal( + id: $id + object: { + event: $event + entry: $entry + privacy_setting_id: $privacy_setting_id + tags: { category: "", spoiler: false, tag: "" } + action_at: $action_at + metadata: { + position: { type: pages, value: $page, possible: $possible, percent: $percent } + } + } + ) { errors id } +} +`; diff --git a/apps/readest-app/src/services/hardcover/index.ts b/apps/readest-app/src/services/hardcover/index.ts new file mode 100644 index 00000000..717daefa --- /dev/null +++ b/apps/readest-app/src/services/hardcover/index.ts @@ -0,0 +1,2 @@ +export { HardcoverClient } from './HardcoverClient'; +export { HardcoverSyncMapStore } from './HardcoverSyncMapStore'; diff --git a/apps/readest-app/src/services/metadata/types.ts b/apps/readest-app/src/services/metadata/types.ts index 6c582e7c..95295fb2 100644 --- a/apps/readest-app/src/services/metadata/types.ts +++ b/apps/readest-app/src/services/metadata/types.ts @@ -6,6 +6,7 @@ export interface Metadata { published?: string; language?: string; identifier?: string; + isbn?: string; subjects?: string[]; description?: string; coverImageUrl?: string; diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 4ebd51ed..2ae15a3c 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -367,6 +367,9 @@ export interface BookConfig { lastSyncedAtNotes?: number; foliateImportedAt?: number; + // Per-book switch for hardcover exports in reader menu. + hardcoverSyncEnabled?: boolean; + updatedAt: number; } diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 07623345..83cff57a 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -70,6 +70,12 @@ export interface ReadwiseSettings { lastSyncedAt: number; } +export interface HardcoverSettings { + enabled: boolean; + accessToken: string; + lastSyncedAt: number; +} + export interface SystemSettings { version: number; localBooksDir: string; @@ -108,6 +114,7 @@ export interface SystemSettings { kosync: KOSyncSettings; readwise: ReadwiseSettings; + hardcover: HardcoverSettings; lastSyncedAtBooks: number; lastSyncedAtConfigs: number; diff --git a/apps/readest-app/src/utils/isbn.ts b/apps/readest-app/src/utils/isbn.ts new file mode 100644 index 00000000..93bddffa --- /dev/null +++ b/apps/readest-app/src/utils/isbn.ts @@ -0,0 +1,58 @@ +import { BookDoc } from '@/libs/document'; +import { validateISBN } from '@/utils/validation'; + +export const extractIsbnCandidates = (value: unknown): string[] => { + if (!value) return []; + + if (Array.isArray(value)) { + return value.flatMap((entry) => extractIsbnCandidates(entry)); + } + + if (typeof value === 'object') { + const identifier = value as { scheme?: string; value?: string }; + const candidates = identifier.value ? extractIsbnCandidates(identifier.value) : []; + if (identifier.scheme?.toLowerCase() === 'isbn' && identifier.value) { + candidates.unshift(identifier.value); + } + return candidates; + } + + if (typeof value !== 'string') return []; + + const trimmed = value.trim(); + if (!trimmed) return []; + + const candidates = [trimmed]; + const prefixedMatches = trimmed.match( + /(?:^|[\s,;])(?:urn:)?isbn:([0-9xX-]{10,17})(?=$|[\s,;])/gi, + ); + if (prefixedMatches) { + for (const match of prefixedMatches) { + candidates.push(match.replace(/^[\s,;]+/, '').replace(/^(?:urn:)?isbn:/i, '')); + } + } + + return candidates; +}; + +export const normalizeMetadataIsbn = (metadata: BookDoc['metadata']) => { + const existingIsbn = metadata.isbn ? validateISBN(metadata.isbn) : null; + if (existingIsbn?.isValid && existingIsbn.value) { + metadata.isbn = existingIsbn.value; + return; + } + + const candidates = [ + ...extractIsbnCandidates(metadata.identifier), + ...extractIsbnCandidates(metadata.altIdentifier), + ]; + + for (const candidate of candidates) { + const normalized = candidate.replace(/^(?:urn:)?isbn:/i, '').trim(); + const validation = validateISBN(normalized); + if (validation.isValid && validation.value) { + metadata.isbn = validation.value; + return; + } + } +};