feat(rsvp): sync reading position to cloud via book_configs (#3801)

This commit is contained in:
Lex Moulton
2026-04-09 09:52:49 -04:00
committed by GitHub
parent 1e259e87b2
commit baee85e7c8
8 changed files with 120 additions and 6 deletions
@@ -1,7 +1,12 @@
import { describe, it, expect } from 'vitest';
import { transformBookNoteToDB, transformBookNoteFromDB } from '@/utils/transform';
import { BookNote } from '@/types/book';
import { DBBookNote } from '@/types/records';
import {
transformBookNoteToDB,
transformBookNoteFromDB,
transformBookConfigToDB,
transformBookConfigFromDB,
} from '@/utils/transform';
import { BookConfig, BookNote } from '@/types/book';
import { DBBookConfig, DBBookNote } from '@/types/records';
describe('transformBookNoteToDB with xpointer fields', () => {
it('passes through xpointer0 and xpointer1', () => {
@@ -112,3 +117,59 @@ describe('transformBookNoteFromDB with xpointer fields', () => {
expect(note.xpointer1).toBe('/body/DocFragment[1]/body/p[1]/text().20');
});
});
describe('transformBookConfigToDB / transformBookConfigFromDB rsvpPosition', () => {
const baseConfig: BookConfig = {
bookHash: 'hash1',
updatedAt: 1700000000000,
};
it('serializes rsvpPosition to JSON string in DB record', () => {
const config: BookConfig = {
...baseConfig,
rsvpPosition: { cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' },
};
const db = transformBookConfigToDB(config, 'user1');
expect(db.rsvp_position).toBe(
JSON.stringify({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' }),
);
});
it('omits rsvp_position when rsvpPosition is undefined', () => {
const db = transformBookConfigToDB(baseConfig, 'user1');
expect(db.rsvp_position).toBeUndefined();
});
it('deserializes rsvp_position from DB record', () => {
const dbConfig: DBBookConfig = {
user_id: 'user1',
book_hash: 'hash1',
rsvp_position: JSON.stringify({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' }),
updated_at: '2023-11-14T22:13:20.000Z',
};
const config = transformBookConfigFromDB(dbConfig);
expect(config.rsvpPosition).toEqual({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' });
});
it('leaves rsvpPosition undefined when rsvp_position is absent from DB', () => {
const dbConfig: DBBookConfig = {
user_id: 'user1',
book_hash: 'hash1',
updated_at: '2023-11-14T22:13:20.000Z',
};
const config = transformBookConfigFromDB(dbConfig);
expect(config.rsvpPosition).toBeUndefined();
});
it('round-trips rsvpPosition through DB transform', () => {
const config: BookConfig = {
...baseConfig,
rsvpPosition: { cfi: 'epubcfi(/6/8!/4/2/3:5)', wordText: 'world' },
};
const db = transformBookConfigToDB(config, 'user1');
// Simulate what DB returns (updated_at as ISO string)
const dbRecord: DBBookConfig = { ...db, updated_at: new Date(config.updatedAt).toISOString() };
const restored = transformBookConfigFromDB(dbRecord);
expect(restored.rsvpPosition).toEqual(config.rsvpPosition);
});
});
@@ -4,9 +4,11 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useThemeStore } from '@/store/themeStore';
import { RSVPController, RsvpStartChoice, RsvpStopPosition } from '@/services/rsvp';
import { eventDispatcher } from '@/utils/event';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { BookNote } from '@/types/book';
import { Insets } from '@/types/misc';
@@ -103,8 +105,10 @@ const expandRangeToSentence = (range: Range, doc: Document): Range => {
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getView, getProgress } = useReaderStore();
const { getBookData } = useBookDataStore();
const { getBookData, getConfig, setConfig, saveConfig } = useBookDataStore();
const { themeCode } = useThemeStore();
const [isActive, setIsActive] = useState(false);
@@ -198,6 +202,13 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const controller = controllerRef.current;
// Seed localStorage from cloud-synced BookConfig when this device has no local position.
// This restores RSVP progress saved on another device after a sync.
const configPos = getConfig(bookKey)?.rsvpPosition;
if (configPos) {
controller.seedPosition(configPos);
}
// Set current CFI for position tracking
if (progress?.location) {
controller.setCurrentCfi(progress.location);
@@ -226,7 +237,7 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
controller.removeEventListener('rsvp-start-choice', handleStartChoice);
}, 100);
},
[_, bookKey, getBookData, getProgress, getView, removeRsvpHighlight],
[_, bookKey, getBookData, getConfig, getProgress, getView, removeRsvpHighlight],
);
const handleStartDialogSelect = useCallback(
@@ -374,9 +385,29 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
controller.stop();
}
// Persist RSVP position to BookConfig so it syncs to the cloud
const rsvpPosition = controller?.getStoredPosition();
if (rsvpPosition) {
const config = getConfig(bookKey);
if (config) {
setConfig(bookKey, { rsvpPosition });
saveConfig(envConfig, bookKey, { ...config, rsvpPosition }, settings);
}
}
setIsActive(false);
setShowStartDialog(false);
}, [bookKey, getView, removeRsvpHighlight, themeCode.primary]);
}, [
bookKey,
envConfig,
getConfig,
getView,
removeRsvpHighlight,
saveConfig,
setConfig,
settings,
themeCode.primary,
]);
const handleChapterSelect = useCallback(
(href: string) => {
@@ -208,6 +208,17 @@ export class RSVPController extends EventTarget {
localStorage.removeItem(`${POSITION_KEY_PREFIX}${this.bookId}`);
}
seedPosition(position: RsvpPosition): void {
const storageKey = `${POSITION_KEY_PREFIX}${this.bookId}`;
if (!localStorage.getItem(storageKey)) {
localStorage.setItem(storageKey, JSON.stringify(position));
}
}
getStoredPosition(): RsvpPosition | null {
return this.loadPositionFromStorage();
}
private getSpineIndex(cfi: string): number {
try {
return XCFI.extractSpineIndex(cfi);
+1
View File
@@ -368,6 +368,7 @@ export interface BookConfig {
location?: string; // CFI of the current location
xpointer?: string; // XPointer of the current location (for Koreader interoperability)
booknotes?: BookNote[];
rsvpPosition?: { cfi: string; wordText: string };
searchConfig?: Partial<BookSearchConfig>;
viewSettings?: Partial<ViewSettings>;
+1
View File
@@ -26,6 +26,7 @@ export interface DBBookConfig {
location?: string;
xpointer?: string;
progress?: string;
rsvp_position?: string;
search_config?: string;
view_settings?: string;
+4
View File
@@ -18,6 +18,7 @@ export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DB
progress,
location,
xpointer,
rsvpPosition,
searchConfig,
viewSettings,
updatedAt,
@@ -30,6 +31,7 @@ export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DB
location: location,
xpointer: xpointer,
progress: progress && JSON.stringify(progress),
rsvp_position: rsvpPosition && JSON.stringify(rsvpPosition),
search_config: searchConfig && JSON.stringify(searchConfig),
view_settings: viewSettings && JSON.stringify(viewSettings),
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
@@ -43,6 +45,7 @@ export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfi
progress,
location,
xpointer,
rsvp_position,
search_config,
view_settings,
updated_at,
@@ -53,6 +56,7 @@ export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfi
location,
xpointer,
progress: progress && JSON.parse(progress),
rsvpPosition: rsvp_position && JSON.parse(rsvp_position),
searchConfig: search_config && JSON.parse(search_config),
viewSettings: view_settings && JSON.parse(view_settings),
updatedAt: new Date(updated_at!).getTime(),
+1
View File
@@ -37,6 +37,7 @@ CREATE TABLE public.book_configs (
location text NULL,
xpointer text NULL,
progress jsonb NULL,
rsvp_position text NULL,
search_config jsonb NULL,
view_settings jsonb NULL,
created_at timestamp with time zone NULL DEFAULT now(),
@@ -0,0 +1,4 @@
-- Migration 001: Add rsvp_position column to book_configs
ALTER TABLE public.book_configs
ADD COLUMN IF NOT EXISTS rsvp_position text NULL;