forked from akai/readest
feat: Readwise highlights sync (#3311)
This commit is contained in:
@@ -68,6 +68,7 @@ vi.mock('@/services/environment', async (importOriginal) => {
|
||||
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { DEFAULT_SYSTEM_SETTINGS } from '@/services/constants';
|
||||
|
||||
function renderWithProviders(ui: React.ReactNode) {
|
||||
return render(
|
||||
@@ -81,12 +82,7 @@ describe('ProofreadRulesManager', () => {
|
||||
beforeEach(() => {
|
||||
// Reset stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
settings: DEFAULT_SYSTEM_SETTINGS,
|
||||
});
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
|
||||
useSidebarStore.setState({ sideBarBookKey: null });
|
||||
@@ -101,6 +97,7 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange: populate stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [
|
||||
{
|
||||
@@ -127,7 +124,6 @@ describe('ProofreadRulesManager', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -164,8 +160,8 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange: populate stores with a selection rule persisted in book config
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -284,10 +280,10 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [libraryRule],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -364,8 +360,8 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -418,8 +414,8 @@ describe('ProofreadRulesManager', () => {
|
||||
// Arrange stores
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
(useReaderStore.setState as unknown as (state: unknown) => void)({
|
||||
@@ -453,8 +449,8 @@ describe('ProofreadRulesManager', () => {
|
||||
it('shows empty state messages when no rules exist', async () => {
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: { proofreadRules: [] },
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -509,10 +505,10 @@ describe('ProofreadRulesManager', () => {
|
||||
|
||||
(useSettingsStore.setState as unknown as (state: unknown) => void)({
|
||||
settings: {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
globalViewSettings: {
|
||||
proofreadRules: [libraryRule],
|
||||
},
|
||||
kosync: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
LibraryGroupByType,
|
||||
LibrarySortByType,
|
||||
ReadSettings,
|
||||
ReadwiseSettings,
|
||||
SystemSettings,
|
||||
} from '@/types/settings';
|
||||
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/quota';
|
||||
@@ -64,6 +65,14 @@ export const DEFAULT_KOSYNC_SETTINGS = {
|
||||
enabled: false,
|
||||
} as KOSyncSettings;
|
||||
|
||||
export const READWISE_API_BASE_URL = 'https://readwise.io/api/v2';
|
||||
|
||||
export const DEFAULT_READWISE_SETTINGS = {
|
||||
enabled: false,
|
||||
accessToken: '',
|
||||
lastSyncedAt: 0,
|
||||
} as ReadwiseSettings;
|
||||
|
||||
export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
keepLogin: false,
|
||||
autoUpload: true,
|
||||
@@ -93,6 +102,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
metadataDescriptionCollapsed: false,
|
||||
|
||||
kosync: DEFAULT_KOSYNC_SETTINGS,
|
||||
readwise: DEFAULT_READWISE_SETTINGS,
|
||||
aiSettings: DEFAULT_AI_SETTINGS,
|
||||
|
||||
lastSyncedAtBooks: 0,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Book, BookNote, HighlightColor } from '@/types/book';
|
||||
import { ReadwiseSettings } from '@/types/settings';
|
||||
import { READWISE_API_BASE_URL } from '@/services/constants';
|
||||
|
||||
const READEST_TO_READWISE_COLOR: Record<HighlightColor, string> = {
|
||||
red: 'pink',
|
||||
yellow: 'yellow',
|
||||
green: 'green',
|
||||
blue: 'blue',
|
||||
violet: 'purple',
|
||||
};
|
||||
|
||||
export class ReadwiseClient {
|
||||
private config: ReadwiseSettings;
|
||||
|
||||
constructor(config: ReadwiseSettings) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async request(
|
||||
endpoint: string,
|
||||
options: { method?: 'GET' | 'POST'; body?: string } = {},
|
||||
): Promise<Response> {
|
||||
const { method = 'GET', body } = options;
|
||||
return fetch(`${READWISE_API_BASE_URL}${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Token ${this.config.accessToken}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async validateToken(): Promise<{ valid: boolean; isNetworkError?: boolean }> {
|
||||
try {
|
||||
const res = await this.request('/auth/');
|
||||
return { valid: res.status === 204 };
|
||||
} catch {
|
||||
return { valid: false, isNetworkError: true };
|
||||
}
|
||||
}
|
||||
|
||||
async pushHighlights(
|
||||
notes: BookNote[],
|
||||
book: Book,
|
||||
): Promise<{ success: boolean; message?: string; isNetworkError?: boolean }> {
|
||||
const syncable = notes.filter(
|
||||
(n) => (n.type === 'annotation' || n.type === 'excerpt') && !n.deletedAt && n.text,
|
||||
);
|
||||
if (syncable.length === 0) return { success: true };
|
||||
|
||||
const isPublicUrl = (url?: string | null) =>
|
||||
!!url && /^https?:\/\/(?!localhost|127\.|asset\.localhost)/.test(url);
|
||||
|
||||
const highlights = syncable.map((note) => ({
|
||||
text: note.text!,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
...(isPublicUrl(book.coverImageUrl) ? { image_url: book.coverImageUrl } : {}),
|
||||
source_type: 'readest',
|
||||
category: 'books',
|
||||
note: note.note || undefined,
|
||||
location: 0,
|
||||
location_type: 'none',
|
||||
highlighted_at: new Date(note.createdAt).toISOString(),
|
||||
highlight_url: `readest://annotation/${book.hash}/${note.id}`,
|
||||
color: note.color ? (READEST_TO_READWISE_COLOR[note.color] ?? 'yellow') : 'yellow',
|
||||
}));
|
||||
|
||||
try {
|
||||
const res = await this.request('/highlights/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ highlights }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '');
|
||||
console.error('Readwise API error:', res.status, errText);
|
||||
let message = `HTTP ${res.status}`;
|
||||
try {
|
||||
const err = JSON.parse(errText);
|
||||
message = err.detail || err.message || JSON.stringify(err) || message;
|
||||
} catch {
|
||||
if (errText) message = errText;
|
||||
}
|
||||
return { success: false, message };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, message: (e as Error).message, isNetworkError: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ReadwiseClient } from './ReadwiseClient';
|
||||
@@ -547,7 +547,8 @@ foliate-fxl {
|
||||
color: theme('colors.base-content') !important;
|
||||
}
|
||||
|
||||
[data-eink='true'] .btn-primary {
|
||||
[data-eink='true'] .btn-primary,
|
||||
[data-eink='true'] .btn-outline {
|
||||
background-color: theme('colors.base-content') !important;
|
||||
border: 1px solid theme('colors.base-content') !important;
|
||||
color: theme('colors.base-100') !important;
|
||||
|
||||
@@ -64,6 +64,12 @@ export interface KOSyncSettings {
|
||||
strategy: KOSyncStrategy;
|
||||
}
|
||||
|
||||
export interface ReadwiseSettings {
|
||||
enabled: boolean;
|
||||
accessToken: string;
|
||||
lastSyncedAt: number;
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
version: number;
|
||||
localBooksDir: string;
|
||||
@@ -101,6 +107,7 @@ export interface SystemSettings {
|
||||
metadataDescriptionCollapsed: boolean;
|
||||
|
||||
kosync: KOSyncSettings;
|
||||
readwise: ReadwiseSettings;
|
||||
|
||||
lastSyncedAtBooks: number;
|
||||
lastSyncedAtConfigs: number;
|
||||
|
||||
Reference in New Issue
Block a user