forked from akai/readest
feat(rsvp): sync reading position to cloud via book_configs (#3801)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface DBBookConfig {
|
||||
location?: string;
|
||||
xpointer?: string;
|
||||
progress?: string;
|
||||
rsvp_position?: string;
|
||||
search_config?: string;
|
||||
view_settings?: string;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user