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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -367,6 +367,9 @@ export interface BookConfig {
|
||||
lastSyncedAtNotes?: number;
|
||||
foliateImportedAt?: number;
|
||||
|
||||
// Per-book switch for hardcover exports in reader menu.
|
||||
hardcoverSyncEnabled?: boolean;
|
||||
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user