Files
readest/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx
T
AK Venugopal 80cab8e56d 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
2026-04-03 09:06:43 +02:00

273 lines
11 KiB
TypeScript

import clsx from 'clsx';
import React from 'react';
import { MdCheck } from 'react-icons/md';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useReaderStore } from '@/store/readerStore';
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';
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
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';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
interface BookMenuProps {
menuClassName?: string;
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
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();
const { sideBarBookKey } = useSidebarStore();
const { parallelViews, setParallel, unsetParallel } = useParallelViewStore();
const viewSettings = getViewSettings(sideBarBookKey!);
const [isSortedTOC, setIsSortedTOC] = React.useState(viewSettings?.sortedTOC || false);
const hardcoverSyncEnabledForBook = !!(
sideBarBookKey && getConfig(sideBarBookKey)?.hardcoverSyncEnabled
);
const handleParallelView = (id: string) => {
openParallelView(id);
setIsDropdownOpen?.(false);
};
const handleReloadPage = () => {
window.location.reload();
setIsDropdownOpen?.(false);
};
const showAboutReadest = () => {
setAboutDialogVisible(true);
setIsDropdownOpen?.(false);
};
const downloadReadest = () => {
window.open(DOWNLOAD_READEST_URL, '_blank');
setIsDropdownOpen?.(false);
};
const handleExportAnnotations = () => {
eventDispatcher.dispatch('export-annotations', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const handleToggleSortTOC = () => {
setIsSortedTOC((prev) => !prev);
setIsDropdownOpen?.(false);
if (sideBarBookKey) {
const viewSettings = getViewSettings(sideBarBookKey)!;
viewSettings.sortedTOC = !isSortedTOC;
setViewSettings(sideBarBookKey, viewSettings);
recreateViewer(envConfig, sideBarBookKey);
}
};
const handleSetParallel = () => {
setParallel(bookKeys);
setIsDropdownOpen?.(false);
};
const handleUnsetParallel = () => {
unsetParallel(bookKeys);
setIsDropdownOpen?.(false);
};
const showKoSyncSettingsWindow = () => {
setKOSyncSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const showProofreadRulesWindow = () => {
setProofreadRulesVisibility(true);
setIsDropdownOpen?.(false);
};
const handlePullKOSync = () => {
eventDispatcher.dispatch('pull-kosync', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const handlePushKOSync = () => {
eventDispatcher.dispatch('push-kosync', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const showReadwiseSettingsWindow = () => {
setReadwiseSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const handlePushReadwise = () => {
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);
setIsDropdownOpen?.(false);
if (discordRichPresenceEnabled && !user) {
navigateToLogin(router);
}
};
return (
<Menu
className={clsx('book-menu dropdown-content z-20 shadow-2xl', menuClassName)}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem
label={_('Parallel Read')}
buttonClass={bookKeys.length > 1 ? 'lg:tooltip lg:tooltip-bottom' : ''}
tooltip={parallelViews.length > 0 ? _('Disable') : _('Enable')}
Icon={parallelViews.length > 0 && bookKeys.length > 1 ? MdCheck : undefined}
>
<ul className='max-h-60 overflow-y-auto'>
{getVisibleLibrary()
.filter((book) => !FIXED_LAYOUT_FORMATS.has(book.format))
.filter((book) => !!book.downloadedAt)
.slice(0, 20)
.map((book) => (
<MenuItem
key={book.hash}
Icon={
<img
src={book.coverImageUrl!}
alt={book.title}
width={56}
height={80}
className='aspect-auto max-h-8 max-w-4 rounded-sm shadow-md'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
}
label={book.title}
labelClass='max-w-36'
onClick={() => handleParallelView(book.hash)}
/>
))}
</ul>
</MenuItem>
{bookKeys.length > 1 &&
(parallelViews.length > 0 ? (
<MenuItem label={_('Exit Parallel Read')} onClick={handleUnsetParallel} />
) : (
<MenuItem label={_('Enter Parallel Read')} onClick={handleSetParallel} />
))}
<hr aria-hidden='true' className='border-base-200 my-1' />
{settings.kosync.enabled ? (
<MenuItem label={_('KOReader Sync')} detailsOpen={false} buttonClass='py-2'>
<ul className='flex flex-col ps-1'>
<MenuItem label={_('Config')} noIcon onClick={showKoSyncSettingsWindow} />
<MenuItem label={_('Push Progress')} noIcon onClick={handlePushKOSync} />
<MenuItem label={_('Pull Progress')} noIcon onClick={handlePullKOSync} />
</ul>
</MenuItem>
) : (
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
)}
{settings.readwise.enabled ? (
<MenuItem label={_('Readwise Sync')} detailsOpen={false} buttonClass='py-2'>
<ul className='flex flex-col ps-1'>
<MenuItem label={_('Config')} noIcon onClick={showReadwiseSettingsWindow} />
<MenuItem label={_('Push Highlights')} noIcon onClick={handlePushReadwise} />
</ul>
</MenuItem>
) : (
<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' />
<MenuItem
label={_('Show on Discord')}
tooltip={_("Display what I'm reading on Discord")}
toggled={settings.discordRichPresenceEnabled}
onClick={toggleDiscordPresence}
/>
</>
)}
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Proofread')} onClick={showProofreadRulesWindow} />
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
<MenuItem
label={_('Sort TOC by Page')}
Icon={isSortedTOC ? MdCheck : undefined}
onClick={handleToggleSortTOC}
/>
<MenuItem label={_('Reload Page')} shortcut='Shift+R' onClick={handleReloadPage} />
<hr aria-hidden='true' className='border-base-200 my-1' />
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
</Menu>
);
};
export default BookMenu;