feat: Readwise highlights sync (#3311)

This commit is contained in:
Matt Vogel
2026-02-23 11:08:59 -05:00
committed by GitHub
parent b99c1bc19a
commit ce53cd2b47
39 changed files with 998 additions and 48 deletions
@@ -24,6 +24,7 @@ import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { KOSyncSettingsWindow } from './KOSyncSettings';
import { ReadwiseSettingsWindow } from './ReadwiseSettings';
import { ProofreadRulesManager } from './ProofreadRules';
import { Toast } from '@/components/Toast';
import { getLocale } from '@/utils/misc';
@@ -169,6 +170,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<AboutWindow />
<UpdaterWindow />
<KOSyncSettingsWindow />
<ReadwiseSettingsWindow />
<ProofreadRulesManager />
<Toast />
</Suspense>
@@ -0,0 +1,172 @@
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 { ReadwiseClient } from '@/services/readwise';
import Dialog from '@/components/Dialog';
export const setReadwiseSettingsWindowVisible = (visible: boolean) => {
const dialog = document.getElementById('readwise_settings_window');
if (dialog) {
const event = new CustomEvent('setReadwiseSettingsVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
export const ReadwiseSettingsWindow: 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.readwise?.accessToken;
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
if (event.detail.visible) {
setAccessToken('');
}
};
const el = document.getElementById('readwise_settings_window');
el?.addEventListener('setReadwiseSettingsVisibility', handleCustomEvent as EventListener);
return () => {
el?.removeEventListener('setReadwiseSettingsVisibility', handleCustomEvent as EventListener);
};
}, []);
const handleConnect = async () => {
setIsConnecting(true);
try {
const client = new ReadwiseClient({ enabled: true, accessToken, lastSyncedAt: 0 });
const { valid, isNetworkError } = await client.validateToken();
if (valid) {
const newSettings = {
...settings,
readwise: {
enabled: true,
accessToken,
lastSyncedAt: settings.readwise?.lastSyncedAt ?? 0,
},
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
} else if (isNetworkError) {
eventDispatcher.dispatch('toast', {
message: _('Unable to connect to Readwise. Please check your network connection.'),
type: 'error',
});
} else {
eventDispatcher.dispatch('toast', {
message: _('Invalid Readwise access token'),
type: 'error',
});
}
} finally {
setIsConnecting(false);
setAccessToken('');
}
};
const handleDisconnect = async () => {
const newSettings = {
...settings,
readwise: { enabled: false, accessToken: '', lastSyncedAt: 0 },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
eventDispatcher.dispatch('toast', { message: _('Disconnected from Readwise'), type: 'info' });
};
const handleToggleEnabled = async () => {
const newSettings = {
...settings,
readwise: { ...settings.readwise, enabled: !settings.readwise?.enabled },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const lastSyncedAt = settings.readwise?.lastSyncedAt ?? 0;
const lastSyncedLabel = lastSyncedAt ? new Date(lastSyncedAt).toLocaleString() : _('Never');
return (
<Dialog
id='readwise_settings_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('Readwise 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 Readwise')}</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.readwise?.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 Readwise account to sync highlights.')}
</p>
<p className='text-base-content/60 text-center text-xs'>
{_('Get your access token at')}{' '}
<a
href='https://readwise.io/access_token'
target='_blank'
rel='noopener noreferrer'
className='link link-primary'
>
readwise.io/access_token
</a>
</p>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Access Token')}</span>
</label>
<input
type='password'
placeholder={_('Paste your Readwise access 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>
);
};
@@ -18,6 +18,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { useReadwiseSync } from '../../hooks/useReadwiseSync';
import { useTextSelector } from '../../hooks/useTextSelector';
import { Position, TextSelection } from '@/utils/sel';
import { getPopupPosition, getPosition, getTextFromRange } from '@/utils/sel';
@@ -51,6 +52,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { listenToNativeTouchEvents } = useDeviceControlStore();
useNotesSync(bookKey);
useReadwiseSync(bookKey);
const osPlatform = getOSPlatform();
const config = getConfig(bookKey)!;
@@ -19,6 +19,7 @@ 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 { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import useBooksManager from '../../hooks/useBooksManager';
@@ -99,6 +100,14 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
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 toggleDiscordPresence = () => {
const discordRichPresenceEnabled = !settings.discordRichPresenceEnabled;
saveSysSettings(envConfig, 'discordRichPresenceEnabled', discordRichPresenceEnabled);
@@ -160,12 +169,26 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
<MenuItem label={_('Enter Parallel Read')} onClick={handleSetParallel} />
))}
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
{settings.kosync.enabled && (
<>
<MenuItem label={_('Push Progress')} onClick={handlePushKOSync} />
<MenuItem label={_('Pull Progress')} onClick={handlePullKOSync} />
</>
{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} />
)}
{appService?.isDesktopApp && (
<>
@@ -0,0 +1,111 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { debounce } from '@/utils/debounce';
import { ReadwiseClient } from '@/services/readwise';
const READWISE_SYNC_DEBOUNCE_MS = 5000;
export const useReadwiseSync = (bookKey: string) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
// Read settings from store at call time to avoid stale closures
const updateLastSyncedAt = useCallback(
async (timestamp: number) => {
const { settings, setSettings, saveSettings } = useSettingsStore.getState();
const newSettings = {
...settings,
readwise: { ...settings.readwise, lastSyncedAt: timestamp },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
},
[envConfig],
);
// useMemo (not useCallback) so the debounce timer isn't reset on every render
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedPush = useMemo(
() =>
debounce(async () => {
const { settings } = useSettingsStore.getState();
if (!settings.readwise?.enabled || !settings.readwise?.accessToken) return;
const client = new ReadwiseClient(settings.readwise);
const book = getBookData(bookKey)?.book;
const config = getConfig(bookKey);
if (!book || !config?.booknotes) return;
const lastSyncedAt = settings.readwise.lastSyncedAt ?? 0;
const newNotes = config.booknotes.filter(
(n) => n.updatedAt > lastSyncedAt || (n.deletedAt ?? 0) > lastSyncedAt,
);
if (newNotes.length === 0) return;
const result = await client.pushHighlights(newNotes, book);
if (result.success) {
await updateLastSyncedAt(Date.now());
} else if (!result.isNetworkError) {
console.error('Readwise sync failed:', result.message);
}
}, READWISE_SYNC_DEBOUNCE_MS),
[bookKey, getBookData, getConfig, updateLastSyncedAt],
);
// Manual "Push All": sends every annotation/excerpt regardless of sync timestamp
const pushAllHighlights = useCallback(async () => {
const { settings } = useSettingsStore.getState();
if (!settings.readwise?.enabled || !settings.readwise?.accessToken) return;
const client = new ReadwiseClient(settings.readwise);
const book = getBookData(bookKey)?.book;
const config = getConfig(bookKey);
if (!book || !config?.booknotes) return;
const result = await client.pushHighlights(config.booknotes, book);
if (result.success) {
await updateLastSyncedAt(Date.now());
eventDispatcher.dispatch('toast', {
message: _('Highlights synced to Readwise'),
type: 'success',
});
} else {
const message = result.isNetworkError
? _('Readwise sync failed: no internet connection')
: _('Readwise sync failed: {{error}}', { error: result.message });
eventDispatcher.dispatch('toast', { message, type: 'error' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey, getBookData, getConfig, updateLastSyncedAt]);
// Cancel any pending debounced sync on unmount to avoid background network requests
useEffect(() => {
return () => {
debouncedPush.cancel();
};
}, [debouncedPush]);
// Listen for manual push-all events dispatched from BookMenu / BooknoteView
useEffect(() => {
const handlePushAll = async (e: CustomEvent) => {
if (e.detail.bookKey !== bookKey) return;
await pushAllHighlights();
};
eventDispatcher.on('readwise-push-all', handlePushAll);
return () => {
eventDispatcher.off('readwise-push-all', handlePushAll);
};
}, [bookKey, pushAllHighlights]);
// Auto-sync whenever booknotes change; debouncedPush reads enabled state internally
const config = getConfig(bookKey);
useEffect(() => {
debouncedPush();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config?.booknotes]);
return { pushAllHighlights };
};