diff --git a/apps/readest-app/src/__tests__/utils/transform.test.ts b/apps/readest-app/src/__tests__/utils/transform.test.ts index b86c7a92..67a11ffd 100644 --- a/apps/readest-app/src/__tests__/utils/transform.test.ts +++ b/apps/readest-app/src/__tests__/utils/transform.test.ts @@ -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); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx index c57b007d..964a4b06 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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) => { diff --git a/apps/readest-app/src/services/rsvp/RSVPController.ts b/apps/readest-app/src/services/rsvp/RSVPController.ts index c856bb07..5f4cc246 100644 --- a/apps/readest-app/src/services/rsvp/RSVPController.ts +++ b/apps/readest-app/src/services/rsvp/RSVPController.ts @@ -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); diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 04408ac5..4b67114e 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -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; viewSettings?: Partial; diff --git a/apps/readest-app/src/types/records.ts b/apps/readest-app/src/types/records.ts index 71996dcd..bf8c91c4 100644 --- a/apps/readest-app/src/types/records.ts +++ b/apps/readest-app/src/types/records.ts @@ -26,6 +26,7 @@ export interface DBBookConfig { location?: string; xpointer?: string; progress?: string; + rsvp_position?: string; search_config?: string; view_settings?: string; diff --git a/apps/readest-app/src/utils/transform.ts b/apps/readest-app/src/utils/transform.ts index 945c9e60..df85a520 100644 --- a/apps/readest-app/src/utils/transform.ts +++ b/apps/readest-app/src/utils/transform.ts @@ -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(), diff --git a/docker/volumes/db/init/schema.sql b/docker/volumes/db/init/schema.sql index 3d0f69f4..779c54cd 100644 --- a/docker/volumes/db/init/schema.sql +++ b/docker/volumes/db/init/schema.sql @@ -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(), diff --git a/docker/volumes/db/migrations/001_add_rsvp_position.sql b/docker/volumes/db/migrations/001_add_rsvp_position.sql new file mode 100644 index 00000000..1be604ff --- /dev/null +++ b/docker/volumes/db/migrations/001_add_rsvp_position.sql @@ -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;