feat: Hardcover.app Sync (#3724)

* 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 <jwt>' 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
This commit is contained in:
AK Venugopal
2026-04-03 08:06:43 +01:00
committed by GitHub
parent 888f4afde9
commit 80cab8e56d
24 changed files with 1998 additions and 21 deletions
@@ -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<unknown>;
};
type MockFetch = ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<MockFetchResponse>>>;
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: <TVariables, TData>(query: string, variables: TVariables) => Promise<TData>;
buildJournalPayload: (
note: BookNote,
config: BookConfig,
context: TestBookContext,
) => { action_at: string; entry: string; event: string };
ensureBookInLibrary: (book: Book, isReading?: boolean) => Promise<TestBookContext | null>;
pushProgress: (book: Book, config: BookConfig) => Promise<void>;
};
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<string, unknown> | 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);
});
});
@@ -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<typeof vi.fn>;
execute: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
};
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<string, string> = {};
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');
});
});
@@ -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', () => {
@@ -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 });
}
}
@@ -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 (
<Dialog
id='hardcover_settings_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('Hardcover Settings')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
>
{isOpen && (
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
<>
<div className='text-center'>
<p className='text-base-content/80 text-sm'>{_('Connected to Hardcover')}</p>
<p className='text-base-content/60 mt-1 text-xs'>
{_('Last synced: {{time}}', { time: lastSyncedLabel })}
</p>
</div>
<div className='flex h-14 items-center justify-between'>
<span className='text-base-content/80'>{_('Sync Enabled')}</span>
<input
type='checkbox'
className='toggle'
checked={settings.hardcover?.enabled ?? false}
onChange={handleToggleEnabled}
/>
</div>
<button className='btn btn-outline btn-sm mt-2' onClick={handleDisconnect}>
{_('Disconnect')}
</button>
</>
) : (
<>
<p className='text-base-content/70 text-center text-sm'>
{_('Connect your Hardcover account to sync reading progress and notes.')}
</p>
<p className='text-base-content/60 text-center text-xs'>
{_('Get your API token from hardcover.app → Settings → API.')}
</p>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('API Token')}</span>
</label>
<input
type='password'
placeholder={_('Paste your Hardcover API token')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
/>
</div>
<button
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
onClick={handleConnect}
disabled={isConnecting || !accessToken}
>
{isConnecting ? <span className='loading loading-spinner'></span> : _('Connect')}
</button>
</>
)}
</div>
)}
</Dialog>
);
};
@@ -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 }) => {
<UpdaterWindow />
<KOSyncSettingsWindow />
<ReadwiseSettingsWindow />
<HardcoverSettingsWindow />
<ProofreadRulesManager />
<Toast />
</Suspense>
@@ -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)!;
@@ -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<BookMenuProps> = ({ 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<BookMenuProps> = ({ 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<BookMenuProps> = ({ 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<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
) : (
<MenuItem label={_('Readwise Sync')} onClick={showReadwiseSettingsWindow} />
)}
{settings.hardcover.enabled ? (
<MenuItem label={_('Hardcover Sync')} detailsOpen={false} buttonClass='py-2'>
<ul className='flex flex-col ps-1'>
<MenuItem label={_('Config')} noIcon onClick={showHardcoverSettingsWindow} />
<MenuItem
label={_('Enable for This Book')}
noIcon
Icon={hardcoverSyncEnabledForBook ? MdCheck : undefined}
onClick={handleToggleHardcoverBookSync}
/>
<MenuItem label={_('Push Progress')} noIcon onClick={handlePushHardcoverProgress} />
<MenuItem label={_('Push Notes')} noIcon onClick={handlePushHardcoverNotes} />
</ul>
</MenuItem>
) : (
<MenuItem label={_('Hardcover Sync')} onClick={showHardcoverSettingsWindow} />
)}
{appService?.isDesktopApp && (
<>
<hr aria-hidden='true' className='border-base-200 my-1' />
@@ -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 };
};
@@ -101,6 +101,12 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
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<BookDetailEditProps> = ({
field: 'identifier',
label: _('Identifier'),
value: metadata.identifier || '',
placeholder: '978-0123456789',
placeholder: _('Keep existing source identifier'),
},
];
const metadataFullwidthFields = [
@@ -89,8 +89,10 @@ const SourceSelector: React.FC<SourceSelectorProps> = ({ sources, isOpen, onSele
{source.data.publisher ? `${source.data.publisher}` : ''}
{source.data.published}
</div>
{source.data.identifier && (
<div className='text-gray-500'>Identifier: {source.data.identifier}</div>
{(source.data.isbn || source.data.identifier) && (
<div className='text-gray-500'>
ISBN: {source.data.isbn || source.data.identifier}
</div>
)}
{source.data.description && (
<div className='line-clamp-3 text-xs text-gray-500'>
@@ -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;
}
+1
View File
@@ -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> | Collection;
@@ -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);
@@ -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<SystemSettings> = {
keepLogin: false,
autoUpload: true,
@@ -103,6 +110,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
kosync: DEFAULT_KOSYNC_SETTINGS,
readwise: DEFAULT_READWISE_SETTINGS,
hardcover: DEFAULT_HARDCOVER_SETTINGS,
aiSettings: DEFAULT_AI_SETTINGS,
lastSyncedAtBooks: 0,
@@ -13,22 +13,24 @@ import { MigrationEntry, SchemaType } from '../migrate';
* 2. Add a new key here with its migration array.
*/
const migrations: Record<SchemaType, MigrationEntry[]> = {
// 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[] {
@@ -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<void> = Promise.resolve();
constructor(settings: HardcoverSettingsLike, mapStore: HardcoverSyncMapStore) {
// Normalize token: Hardcover expects "Bearer <jwt>"; 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<TVariables, TData>(
query: string,
variables: TVariables,
retries = 3,
backoffMs = 2000,
): Promise<TData> {
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<string, never>,
{ 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<BookContext | null> {
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<BookContext | null> {
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<void> {
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<BookContext | null> {
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<void> {
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<string, unknown>,
): Promise<number> {
const data = await this.request<
Record<string, unknown>,
{ 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<string, unknown>): Promise<void> {
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<string>();
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 };
}
}
@@ -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<string, HardcoverSyncMapRow> = 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<T>(fn: (db: Awaited<ReturnType<AppService['openDatabase']>>) => Promise<T>) {
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<void> {
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<HardcoverSyncMapRow>(
`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<void> {
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<HardcoverSyncMapRow | null> {
if (this.loadedBookHash !== bookHash) {
await this.loadForBook(bookHash);
}
return this.mappings.get(noteId) || null;
}
async getMappingByPayloadHash(
bookHash: string,
payloadHash: string,
): Promise<HardcoverSyncMapRow | null> {
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<void> {
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;
}
}
@@ -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 }
}
`;
@@ -0,0 +1,2 @@
export { HardcoverClient } from './HardcoverClient';
export { HardcoverSyncMapStore } from './HardcoverSyncMapStore';
@@ -6,6 +6,7 @@ export interface Metadata {
published?: string;
language?: string;
identifier?: string;
isbn?: string;
subjects?: string[];
description?: string;
coverImageUrl?: string;
+3
View File
@@ -367,6 +367,9 @@ export interface BookConfig {
lastSyncedAtNotes?: number;
foliateImportedAt?: number;
// Per-book switch for hardcover exports in reader menu.
hardcoverSyncEnabled?: boolean;
updatedAt: number;
}
+7
View File
@@ -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;
+58
View File
@@ -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;
}
}
};