feat: add user preferences and document progress syncing

- Implemented user preferences management with a new API for GET and PUT requests.
- Added user document progress tracking with a new API for retrieving and updating progress.
- Introduced database schema changes for user preferences and document progress.
- Enhanced EPUB and TTS contexts to support syncing user preferences and document progress.
- Added functions to handle transferring user preferences and progress during account linking.
- Updated client-side logic to schedule syncing of user preferences and document progress.
This commit is contained in:
Richard R 2026-02-11 13:58:16 -07:00
parent 852092c558
commit 82f82a990e
23 changed files with 1209 additions and 50 deletions

View file

@ -18,3 +18,20 @@ This page covers application-level configuration for provider access and authent
- For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./object-blob-storage)
- For database mode: [Database](./database)
- For migration behavior and commands: [Migrations](./migrations)
## Sync notes
### Auth enabled
- Settings and reading progress are saved to the server.
- Updates are not instant push-based sync; they use normal client polling/refresh behavior.
- If two devices change the same item around the same time, the newest update wins.
### Auth disabled
- Settings and reading progress stay local in the browser (Dexie/IndexedDB).
- This avoids no-auth cross-browser conflicts, but there is no cross-device sync.
## Claim modal note
- You may still see old anonymous settings/progress available to claim from older deployments.

View file

@ -13,6 +13,9 @@ This page covers database mode selection for OpenReader WebUI.
- Document and audiobook metadata/state used by server routes.
- Auth/session tables when auth is enabled.
- TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled).
- User settings preferences (`user_preferences`) when auth is enabled.
- User reading progress (`user_document_progress`) when auth is enabled.
## Related variables
@ -24,3 +27,10 @@ For database variable behavior, see [Environment Variables](../reference/environ
- [Migrations](./migrations)
- [Object / Blob Storage](./object-blob-storage)
- [Auth](./auth)
## State sync summary
- With auth enabled, settings and reading progress are stored in SQL and synced from the app.
- With auth disabled, settings and reading progress remain local in the browser.
- Sync is currently request-based (not realtime push invalidation).

View file

@ -28,6 +28,7 @@ title: Stack
## Next.js server
- APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying
- State sync: request-based today (not realtime push updates)
- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters
- Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`)
- Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3

View file

@ -0,0 +1,21 @@
CREATE TABLE "user_document_progress" (
"user_id" text NOT NULL,
"document_id" text NOT NULL,
"reader_type" text NOT NULL,
"location" text NOT NULL,
"progress" real,
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "user_document_progress_user_id_document_id_pk" PRIMARY KEY("user_id","document_id")
);
--> statement-breakpoint
CREATE TABLE "user_preferences" (
"user_id" text PRIMARY KEY NOT NULL,
"data_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX "idx_user_document_progress_user_id_updated_at" ON "user_document_progress" USING btree ("user_id","updated_at");

View file

@ -8,6 +8,13 @@
"when": 1770191341206,
"tag": "0000_perfect_risque",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1770843600000,
"tag": "0001_user_state_minimal",
"breakpoints": true
}
]
}
}

View file

@ -0,0 +1,21 @@
CREATE TABLE `user_document_progress` (
`user_id` text NOT NULL,
`document_id` text NOT NULL,
`reader_type` text NOT NULL,
`location` text NOT NULL,
`progress` real,
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
PRIMARY KEY(`user_id`, `document_id`)
);
--> statement-breakpoint
CREATE TABLE `user_preferences` (
`user_id` text PRIMARY KEY NOT NULL,
`data_json` text DEFAULT '{}' NOT NULL,
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000)
);
--> statement-breakpoint
CREATE INDEX `idx_user_document_progress_user_id_updated_at` ON `user_document_progress` (`user_id`,`updated_at`);

View file

@ -8,6 +8,13 @@
"when": 1770191340954,
"tag": "0000_lively_korvac",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1770843600000,
"tag": "0001_user_state_minimal",
"breakpoints": true
}
]
}
}

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { claimAnonymousData } from '@/lib/server/claim-data';
import { auth } from '@/lib/server/auth';
import { db } from '@/db';
import { audiobooks, documents } from '@/db/schema';
import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/db/schema';
import { count, eq, ne } from 'drizzle-orm';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
@ -22,7 +22,9 @@ async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
return null;
}
async function getClaimableCounts(unclaimedUserId: string): Promise<{ documents: number; audiobooks: number }> {
async function getClaimableCounts(
unclaimedUserId: string,
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number }> {
const [docCount] = await db
.select({ count: count() })
.from(documents)
@ -31,10 +33,20 @@ async function getClaimableCounts(unclaimedUserId: string): Promise<{ documents:
.select({ count: count() })
.from(audiobooks)
.where(eq(audiobooks.userId, unclaimedUserId));
const [preferencesCount] = await db
.select({ count: count() })
.from(userPreferences)
.where(eq(userPreferences.userId, unclaimedUserId));
const [progressCount] = await db
.select({ count: count() })
.from(userDocumentProgress)
.where(eq(userDocumentProgress.userId, unclaimedUserId));
return {
documents: Number(docCount?.count ?? 0),
audiobooks: Number(bookCount?.count ?? 0),
preferences: Number(preferencesCount?.count ?? 0),
progress: Number(progressCount?.count ?? 0),
};
}

View file

@ -0,0 +1,195 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { userPreferences } from '@/db/schema';
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state';
import { resolveUserStateScope } from '@/lib/server/user-state-scope';
export const dynamic = 'force-dynamic';
function nowForDb(): Date | number {
return process.env.POSTGRES_URL ? new Date() : Date.now();
}
function serializePreferencesForDb(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string {
if (process.env.POSTGRES_URL) return patch;
return JSON.stringify(patch);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function parseStoredPreferences(value: unknown): SyncedPreferencesPatch {
if (!value) return {};
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return isRecord(parsed) ? sanitizePreferencesPatch(parsed) : {};
} catch {
return {};
}
}
return isRecord(value) ? sanitizePreferencesPatch(value) : {};
}
function sanitizeSavedVoices(value: unknown): Record<string, string> {
if (!isRecord(value)) return {};
const out: Record<string, string> = {};
for (const [key, val] of Object.entries(value)) {
if (typeof key !== 'string' || key.length === 0) continue;
if (typeof val !== 'string') continue;
out[key] = val;
}
return out;
}
function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
if (!isRecord(input)) return {};
const out: SyncedPreferencesPatch = {};
for (const key of SYNCED_PREFERENCE_KEYS) {
if (!(key in input)) continue;
const value = input[key];
switch (key) {
case 'viewType':
if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value;
break;
case 'voice':
case 'ttsProvider':
case 'ttsModel':
case 'ttsInstructions':
if (typeof value === 'string') out[key] = value;
break;
case 'voiceSpeed':
case 'audioPlayerSpeed':
case 'headerMargin':
case 'footerMargin':
case 'leftMargin':
case 'rightMargin':
if (Number.isFinite(value)) out[key] = Number(value);
break;
case 'skipBlank':
case 'epubTheme':
case 'smartSentenceSplitting':
case 'pdfHighlightEnabled':
case 'pdfWordHighlightEnabled':
case 'epubHighlightEnabled':
case 'epubWordHighlightEnabled':
if (typeof value === 'boolean') out[key] = value;
break;
case 'savedVoices':
out[key] = sanitizeSavedVoices(value);
break;
default:
break;
}
}
return out;
}
function normalizeClientUpdatedAtMs(value: unknown): number {
if (!Number.isFinite(value)) return Date.now();
const normalized = Number(value);
if (normalized <= 0) return Date.now();
return Math.floor(normalized);
}
export async function GET(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const rows = await db
.select({
dataJson: userPreferences.dataJson,
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
})
.from(userPreferences)
.where(eq(userPreferences.userId, scope.ownerUserId))
.limit(1);
const row = rows[0];
const storedPatch = parseStoredPreferences(row?.dataJson);
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
return NextResponse.json({
preferences: storedPatch,
clientUpdatedAtMs,
hasStoredPreferences: Boolean(row),
});
} catch (error) {
console.error('Error loading user preferences:', error);
return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 });
}
}
export async function PUT(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const body = (await req.json().catch(() => null)) as
| { patch?: unknown; clientUpdatedAtMs?: unknown }
| null;
const patch = sanitizePreferencesPatch(body?.patch);
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
if (Object.keys(patch).length === 0) {
return NextResponse.json({ error: 'No valid preferences provided' }, { status: 400 });
}
const existingRows = await db
.select({
dataJson: userPreferences.dataJson,
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
})
.from(userPreferences)
.where(eq(userPreferences.userId, scope.ownerUserId))
.limit(1);
const existing = existingRows[0];
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
const existingPatch = parseStoredPreferences(existing?.dataJson);
if (existing && clientUpdatedAtMs < existingUpdated) {
return NextResponse.json({
preferences: existingPatch,
clientUpdatedAtMs: existingUpdated,
applied: false,
});
}
const mergedPatch = { ...existingPatch, ...patch };
const dataJson = serializePreferencesForDb(mergedPatch);
const updatedAt = nowForDb();
await db
.insert(userPreferences)
.values({
userId: scope.ownerUserId,
dataJson,
clientUpdatedAtMs,
updatedAt,
})
.onConflictDoUpdate({
target: [userPreferences.userId],
set: {
dataJson,
clientUpdatedAtMs,
updatedAt,
},
});
return NextResponse.json({
preferences: mergedPatch,
clientUpdatedAtMs,
applied: true,
});
} catch (error) {
console.error('Error updating user preferences:', error);
return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 });
}
}

View file

@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { userDocumentProgress } from '@/db/schema';
import type { ReaderType } from '@/types/user-state';
import { isValidDocumentId } from '@/lib/server/documents-blobstore';
import { resolveUserStateScope } from '@/lib/server/user-state-scope';
export const dynamic = 'force-dynamic';
function nowForDb(): Date | number {
return process.env.POSTGRES_URL ? new Date() : Date.now();
}
function normalizeReaderType(value: unknown): ReaderType | null {
if (value === 'pdf' || value === 'epub' || value === 'html') return value;
return null;
}
function normalizeClientUpdatedAtMs(value: unknown): number {
if (!Number.isFinite(value)) return Date.now();
const normalized = Number(value);
if (normalized <= 0) return Date.now();
return Math.floor(normalized);
}
function toUpdatedAtMs(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (value instanceof Date) return value.getTime();
return Date.now();
}
export async function GET(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const documentId = (new URL(req.url).searchParams.get('documentId') || '').trim().toLowerCase();
if (!isValidDocumentId(documentId)) {
return NextResponse.json({ error: 'Invalid documentId' }, { status: 400 });
}
const rows = await db
.select({
documentId: userDocumentProgress.documentId,
readerType: userDocumentProgress.readerType,
location: userDocumentProgress.location,
progress: userDocumentProgress.progress,
clientUpdatedAtMs: userDocumentProgress.clientUpdatedAtMs,
updatedAt: userDocumentProgress.updatedAt,
})
.from(userDocumentProgress)
.where(and(
eq(userDocumentProgress.userId, scope.ownerUserId),
eq(userDocumentProgress.documentId, documentId),
))
.limit(1);
const row = rows[0];
if (!row) {
return NextResponse.json({ progress: null });
}
return NextResponse.json({
progress: {
documentId: row.documentId,
readerType: row.readerType,
location: row.location,
progress: row.progress == null ? null : Number(row.progress),
clientUpdatedAtMs: Number(row.clientUpdatedAtMs ?? 0),
updatedAtMs: toUpdatedAtMs(row.updatedAt),
},
});
} catch (error) {
console.error('Error loading user progress:', error);
return NextResponse.json({ error: 'Failed to load user progress' }, { status: 500 });
}
}
export async function PUT(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const body = (await req.json().catch(() => null)) as
| {
documentId?: unknown;
readerType?: unknown;
location?: unknown;
progress?: unknown;
clientUpdatedAtMs?: unknown;
}
| null;
const documentId = typeof body?.documentId === 'string' ? body.documentId.trim().toLowerCase() : '';
if (!isValidDocumentId(documentId)) {
return NextResponse.json({ error: 'Invalid documentId' }, { status: 400 });
}
const readerType = normalizeReaderType(body?.readerType);
if (!readerType) {
return NextResponse.json({ error: "Invalid readerType. Expected 'pdf', 'epub', or 'html'." }, { status: 400 });
}
const location = typeof body?.location === 'string' ? body.location.trim() : '';
if (!location) {
return NextResponse.json({ error: 'Invalid location' }, { status: 400 });
}
const progress =
body?.progress == null
? null
: Number.isFinite(body.progress)
? Math.max(0, Math.min(1, Number(body.progress)))
: null;
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
const existingRows = await db
.select({
clientUpdatedAtMs: userDocumentProgress.clientUpdatedAtMs,
location: userDocumentProgress.location,
readerType: userDocumentProgress.readerType,
progress: userDocumentProgress.progress,
updatedAt: userDocumentProgress.updatedAt,
})
.from(userDocumentProgress)
.where(and(
eq(userDocumentProgress.userId, scope.ownerUserId),
eq(userDocumentProgress.documentId, documentId),
))
.limit(1);
const existing = existingRows[0];
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
if (existing && clientUpdatedAtMs < existingUpdated) {
return NextResponse.json({
progress: {
documentId,
readerType: existing.readerType,
location: existing.location,
progress: existing.progress == null ? null : Number(existing.progress),
clientUpdatedAtMs: existingUpdated,
updatedAtMs: toUpdatedAtMs(existing.updatedAt),
},
applied: false,
});
}
const updatedAt = nowForDb();
await db
.insert(userDocumentProgress)
.values({
userId: scope.ownerUserId,
documentId,
readerType,
location,
progress,
clientUpdatedAtMs,
updatedAt,
})
.onConflictDoUpdate({
target: [userDocumentProgress.userId, userDocumentProgress.documentId],
set: {
readerType,
location,
progress,
clientUpdatedAtMs,
updatedAt,
},
});
return NextResponse.json({
progress: {
documentId,
readerType,
location,
progress,
clientUpdatedAtMs,
updatedAtMs: toUpdatedAtMs(updatedAt),
},
applied: true,
});
} catch (error) {
console.error('Error updating user progress:', error);
return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 });
}
}

View file

@ -11,6 +11,24 @@ import {
} from '@headlessui/react';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useRouter } from 'next/navigation';
import toast from 'react-hot-toast';
type ClaimableCounts = {
documents: number;
audiobooks: number;
preferences: number;
progress: number;
};
function toClaimableCounts(value: unknown): ClaimableCounts {
const rec = (value && typeof value === 'object') ? (value as Record<string, unknown>) : {};
return {
documents: Number(rec.documents ?? 0),
audiobooks: Number(rec.audiobooks ?? 0),
preferences: Number(rec.preferences ?? 0),
progress: Number(rec.progress ?? 0),
};
}
export default function ClaimDataModal() {
const { data: sessionData } = useAuthSession();
@ -18,6 +36,12 @@ export default function ClaimDataModal() {
const [isOpen, setIsOpen] = useState(false);
const [hasChecked, setHasChecked] = useState(false);
const [isClaiming, setIsClaiming] = useState(false);
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>({
documents: 0,
audiobooks: 0,
preferences: 0,
progress: 0,
});
const user = sessionData?.user;
const checkClaimableData = useCallback(async () => {
@ -29,7 +53,10 @@ export default function ClaimDataModal() {
});
if (res.ok) {
const data = await res.json();
if (data.documents > 0 || data.audiobooks > 0) {
const counts = toClaimableCounts(data);
setClaimableCounts(counts);
if (counts.documents + counts.audiobooks + counts.preferences + counts.progress > 0) {
setIsOpen(true);
}
}
@ -53,13 +80,22 @@ export default function ClaimDataModal() {
});
if (res.ok) {
const data = await res.json();
alert(`Successfully claimed ${data.claimed.documents} documents and ${data.claimed.audiobooks} audiobooks!`);
const claimed = toClaimableCounts(data?.claimed);
toast.success(
`Successfully claimed ${claimed.documents} documents, `
+ `${claimed.audiobooks} audiobooks, `
+ `${claimed.preferences} preference set(s), and `
+ `${claimed.progress} reading progress record(s)!`,
);
setIsOpen(false);
router.refresh();
return;
}
const data = await res.json().catch(() => null) as { error?: string } | null;
toast.error(data?.error || 'Failed to claim data.');
} catch {
alert('Failed to claim data.');
toast.error('Failed to claim data.');
} finally {
setIsClaiming(false);
}
@ -106,12 +142,22 @@ export default function ClaimDataModal() {
</DialogTitle>
<p className="text-sm text-muted mb-2">
We found documents and audiobooks that were created before auth was enabled.
Would you like to claim them and add them to your account?
We found existing anonymous data from before auth was enabled.
Claim it now to attach it to your account.
</p>
<div className="mb-4 rounded-lg border border-offbase bg-offbase/40 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Claimable data</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-foreground/90">
<li>{claimableCounts.documents} document(s)</li>
<li>{claimableCounts.audiobooks} audiobook(s)</li>
<li>{claimableCounts.preferences} preference set(s)</li>
<li>{claimableCounts.progress} reading progress record(s)</li>
</ul>
</div>
<p className="text-xs text-muted/70 mb-6 italic">
First user to claim these files will own them and revoke access for anyone else.
First user to claim this data will own it and revoke access for anyone else.
</p>
<div className="flex justify-end gap-3">
@ -137,7 +183,7 @@ export default function ClaimDataModal() {
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
disabled:opacity-50"
>
{isClaiming ? 'Claiming...' : 'Claim All'}
{isClaiming ? 'Claiming...' : 'Claim Data'}
</Button>
</div>
</DialogPanel>

View file

@ -1,9 +1,13 @@
'use client';
import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
import { scheduleUserPreferencesSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state';
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import toast from 'react-hot-toast';
export type { ViewType } from '@/types/config';
@ -50,10 +54,51 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
const [isDBReady, setIsDBReady] = useState(false);
const didRunStartupMigrations = useRef(false);
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
const { authEnabled } = useAuthConfig();
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
const sessionKey = sessionData?.user?.id ?? 'no-session';
// Helper function to generate provider-model key
const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`;
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
if (!authEnabled) return;
const syncedPatch: SyncedPreferencesPatch = {};
for (const key of SYNCED_PREFERENCE_KEYS) {
if (!(key in patch)) continue;
const value = patch[key];
if (value === undefined) continue;
(syncedPatch as Record<SyncedPreferenceKey, unknown>)[key] = value;
}
if (Object.keys(syncedPatch).length === 0) return;
scheduleUserPreferencesSync(syncedPatch);
}, [authEnabled]);
const buildSyncedPreferencePatch = useCallback((
source: Partial<AppConfigValues>,
options?: { nonDefaultOnly?: boolean },
): SyncedPreferencesPatch => {
const out: SyncedPreferencesPatch = {};
for (const key of SYNCED_PREFERENCE_KEYS) {
if (!(key in source)) continue;
const value = source[key];
if (value === undefined) continue;
if (options?.nonDefaultOnly) {
const defaultValue = APP_CONFIG_DEFAULTS[key];
const same =
typeof value === 'object'
? JSON.stringify(value) === JSON.stringify(defaultValue)
: value === defaultValue;
if (same) continue;
}
(out as Record<SyncedPreferenceKey, unknown>)[key] = value;
}
return out;
}, []);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail;
@ -107,6 +152,38 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
void run();
}, [isDBReady]);
const refreshSyncedPreferencesFromServer = useCallback(async () => {
if (!isDBReady || !authEnabled) return;
try {
const remote = await getUserPreferences();
if (!remote?.hasStoredPreferences) return;
if (!remote.preferences || Object.keys(remote.preferences).length === 0) return;
await updateAppConfig(remote.preferences as Partial<AppConfigRow>);
} catch (error) {
console.warn('Failed to load synced preferences:', error);
}
}, [isDBReady, authEnabled]);
useEffect(() => {
if (!isDBReady || !authEnabled || isSessionPending) return;
refreshSyncedPreferencesFromServer().catch((error) => {
console.warn('Synced preferences refresh failed:', error);
});
}, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
useEffect(() => {
if (!isDBReady || !authEnabled) return;
const onFocus = () => {
refreshSyncedPreferencesFromServer().catch((error) => {
console.warn('Focus synced preferences refresh failed:', error);
});
};
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]);
const appConfig = useLiveQuery(
async () => {
if (!isDBReady) return null;
@ -124,6 +201,32 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
return { ...APP_CONFIG_DEFAULTS, ...rest };
}, [appConfig]);
useEffect(() => {
if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return;
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
didAttemptInitialPreferenceSeedForSession.current = sessionKey;
const run = async () => {
try {
const remote = await getUserPreferences();
if (remote?.hasStoredPreferences) return;
// Seed only user-customized (non-default) values. This prevents fresh/default
// profiles from overwriting existing server values during first-run races.
const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true });
if (Object.keys(patch).length === 0) return;
await putUserPreferences(patch, { clientUpdatedAtMs: Date.now() });
} catch (error) {
console.warn('Failed to seed initial synced preferences from local Dexie:', error);
}
};
run().catch((error) => {
console.warn('Initial synced preferences seed failed:', error);
});
}, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]);
// Destructure for convenience and to match context shape
const {
apiKey,
@ -163,7 +266,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (newConfig.baseUrl !== undefined) {
updates.baseUrl = newConfig.baseUrl;
}
if (newConfig.viewType !== undefined) {
updates.viewType = newConfig.viewType;
}
await updateAppConfig(updates);
queueSyncedPreferencePatch(updates);
} catch (error) {
console.error('Error updating config:', error);
throw error;
@ -189,6 +296,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
savedVoices: updatedSavedVoices,
voice: value as string,
});
queueSyncedPreferencePatch({
savedVoices: updatedSavedVoices,
voice: value as string,
});
}
// Special handling for provider/model changes - restore saved voice if available
else if (key === 'ttsProvider' || key === 'ttsModel') {
@ -200,17 +311,29 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
[key]: value as AppConfigValues[keyof AppConfigValues],
voice: restoredVoice,
} as Partial<AppConfigRow>);
queueSyncedPreferencePatch({
[key]: value as AppConfigValues[keyof AppConfigValues],
voice: restoredVoice,
} as Partial<AppConfigValues>);
}
else if (key === 'savedVoices') {
const newSavedVoices = value as SavedVoices;
await updateAppConfig({
savedVoices: newSavedVoices,
});
queueSyncedPreferencePatch({
savedVoices: newSavedVoices,
});
}
else {
await updateAppConfig({
[key]: value as AppConfigValues[keyof AppConfigValues],
} as Partial<AppConfigRow>);
if (syncedPreferenceKeys.has(String(key))) {
queueSyncedPreferencePatch({
[key]: value,
} as Partial<AppConfigValues>);
}
}
} catch (error) {
console.error(`Error updating config key ${String(key)}:`, error);

View file

@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, R
import type { BaseDocument, DocumentType } from '@/types/documents';
import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client-documents';
import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache';
import { useAuthSession } from '@/hooks/useAuthSession';
interface DocumentContextType {
// PDF Documents
@ -36,6 +37,8 @@ const DocumentContext = createContext<DocumentContextType | undefined>(undefined
export function DocumentProvider({ children }: { children: ReactNode }) {
const [docs, setDocs] = useState<BaseDocument[] | null>(null);
const isLoading = docs === null;
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
const sessionKey = sessionData?.user?.id ?? 'no-session';
const refreshDocuments = useCallback(async () => {
const serverDocs = await listDocuments();
@ -44,11 +47,12 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
}, []);
useEffect(() => {
if (isSessionPending) return;
refreshDocuments().catch((err) => {
console.error('Failed to load documents from server:', err);
setDocs([]);
});
}, [refreshDocuments]);
}, [refreshDocuments, sessionKey, isSessionPending]);
useEffect(() => {
const handler = () => {

View file

@ -17,9 +17,11 @@ import type { SpineItem } from 'epubjs/types/section';
import type { Book, Rendition } from 'epubjs';
import { setLastDocumentLocation } from '@/lib/dexie';
import { scheduleDocumentProgressSync } from '@/lib/client-user-state';
import { getDocumentMetadata } from '@/lib/client-documents';
import { ensureCachedDocument } from '@/lib/document-cache';
import { useTTS } from '@/contexts/TTSContext';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { createRangeCfi } from '@/lib/epub';
import { useParams } from 'next/navigation';
import { useConfig } from './ConfigContext';
@ -171,6 +173,7 @@ const collectContinuationFromRange = (range: Range | null | undefined, limit = E
*/
export function EPUBProvider({ children }: { children: ReactNode }) {
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS();
const { authEnabled } = useAuthConfig();
const { id } = useParams();
// Configuration context to get TTS settings
const {
@ -694,6 +697,13 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
if (id && locationRef.current !== 1) {
console.log('Saving location:', location);
setLastDocumentLocation(id as string, location.toString());
if (authEnabled) {
scheduleDocumentProgressSync({
documentId: id as string,
readerType: 'epub',
location: location.toString(),
});
}
}
skipToLocation(location);
@ -703,7 +713,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current);
shouldPauseRef.current = true;
}
}, [id, skipToLocation, extractPageText, setIsEPUB]);
}, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled]);
const clearWordHighlights = useCallback(() => {
if (!renditionRef.current) return;

View file

@ -27,7 +27,7 @@ import {
} from 'react';
import { Howl } from 'howler';
import toast from 'react-hot-toast';
import { useParams } from 'next/navigation';
import { useParams, usePathname } from 'next/navigation';
import { useConfig } from '@/contexts/ConfigContext';
import { useAudioCache } from '@/hooks/audio/useAudioCache';
@ -35,6 +35,7 @@ import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
import { useMediaSession } from '@/hooks/audio/useMediaSession';
import { useAudioContext } from '@/hooks/audio/useAudioContext';
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client-user-state';
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
import { withRetry, generateTTS, alignAudio } from '@/lib/client';
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp';
@ -53,6 +54,7 @@ import type {
TTSRequestHeaders,
TTSRetryOptions,
} from '@/types/client';
import type { ReaderType } from '@/types/user-state';
// Media globals
declare global {
@ -304,7 +306,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const audioContext = useAudioContext();
const audioCache = useAudioCache(25);
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit, isAtLimit } = useAuthRateLimit();
const {
authEnabled,
onTTSStart,
onTTSComplete,
refresh: refreshRateLimit,
triggerRateLimit,
isAtLimit,
} = useAuthRateLimit();
// Add ref for location change handler
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
@ -332,6 +341,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Get document ID from URL params
const { id } = useParams();
const pathname = usePathname();
const currentReaderType: ReaderType = useMemo(() => {
if (pathname.startsWith('/epub/')) return 'epub';
if (pathname.startsWith('/html/')) return 'html';
return 'pdf';
}, [pathname]);
/**
* State Management
@ -1716,38 +1732,75 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
skipBackward,
});
// Load last location on mount for both EPUB and PDF
// Load last location on mount for both EPUB and PDF.
// Prefer server-backed progress when available, then fall back to local Dexie.
useEffect(() => {
if (id) {
getLastDocumentLocation(id as string).then(lastLocation => {
if (lastLocation) {
console.log('Setting last location:', lastLocation);
if (!id) return;
if (isEPUB && locationChangeHandlerRef.current) {
// For EPUB documents, use the location change handler
locationChangeHandlerRef.current(lastLocation);
} else if (!isEPUB) {
// For PDF documents, parse the location as "page:sentence"
try {
const [pageStr, sentenceIndexStr] = lastLocation.split(':');
const page = parseInt(pageStr, 10);
const sentenceIndex = parseInt(sentenceIndexStr, 10);
let cancelled = false;
const docId = id as string;
if (!isNaN(page) && !isNaN(sentenceIndex)) {
console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`);
// Skip to the page first, then the sentence index will be restored when setText is called
setCurrDocPage(page);
// Store the sentence index to be used when text is loaded
setPendingRestoreIndex(sentenceIndex);
}
} catch (error) {
console.warn('Error parsing PDF location:', error);
}
const applyLocation = (lastLocation: string) => {
console.log('Setting last location:', lastLocation);
if (isEPUB && locationChangeHandlerRef.current) {
// For EPUB documents, use the location change handler
locationChangeHandlerRef.current(lastLocation);
return;
}
if (!isEPUB) {
// For PDF documents, parse the location as "page:sentence"
try {
const [pageStr, sentenceIndexStr] = lastLocation.split(':');
const page = parseInt(pageStr, 10);
const sentenceIndex = parseInt(sentenceIndexStr, 10);
if (!isNaN(page) && !isNaN(sentenceIndex)) {
console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`);
// Skip to the page first, then the sentence index will be restored when setText is called
setCurrDocPage(page);
// Store the sentence index to be used when text is loaded
setPendingRestoreIndex(sentenceIndex);
}
} catch (error) {
console.warn('Error parsing PDF location:', error);
}
});
}
}, [id, isEPUB]);
}
};
const load = async () => {
if (authEnabled) {
try {
const remote = await getDocumentProgress(docId);
if (!cancelled && remote?.location) {
await setLastDocumentLocation(docId, remote.location).catch((error) => {
console.warn('Error caching remote location locally:', error);
});
applyLocation(remote.location);
return;
}
} catch (error) {
console.warn('Error loading remote progress:', error);
}
}
try {
const local = await getLastDocumentLocation(docId);
if (!cancelled && local) {
applyLocation(local);
}
} catch (error) {
console.warn('Error loading local last location:', error);
}
};
load();
return () => {
cancelled = true;
};
}, [id, isEPUB, currentReaderType, authEnabled]);
// Save current position periodically for PDFs
useEffect(() => {
@ -1758,11 +1811,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setLastDocumentLocation(id as string, location).catch(error => {
console.warn('Error saving PDF location:', error);
});
if (authEnabled) {
scheduleDocumentProgressSync({
documentId: id as string,
readerType: currentReaderType,
location,
});
}
}, 1000); // Debounce saves by 1 second
return () => clearTimeout(timeoutId);
}
}, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length]);
}, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]);
/**
* Renders the TTS context provider with its children

View file

@ -12,3 +12,5 @@ export const session = usePostgres ? postgresSchema.session : sqliteSchema.sessi
export const account = usePostgres ? postgresSchema.account : sqliteSchema.account;
export const verification = usePostgres ? postgresSchema.verification : sqliteSchema.verification;
export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars;
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;

View file

@ -1,4 +1,4 @@
import { pgTable, text, integer, real, boolean, timestamp, date, bigint, primaryKey, index } from 'drizzle-orm/pg-core';
import { pgTable, text, integer, real, boolean, timestamp, date, bigint, primaryKey, index, jsonb } from 'drizzle-orm/pg-core';
export const documents = pgTable('documents', {
id: text('id').notNull(),
@ -96,3 +96,25 @@ export const userTtsChars = pgTable("user_tts_chars", {
pk: primaryKey({ columns: [table.userId, table.date] }),
dateIdx: index('idx_user_tts_chars_date').on(table.date),
}));
export const userPreferences = pgTable('user_preferences', {
userId: text('user_id').primaryKey(),
dataJson: jsonb('data_json').notNull().default({}),
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const userDocumentProgress = pgTable('user_document_progress', {
userId: text('user_id').notNull(),
documentId: text('document_id').notNull(),
readerType: text('reader_type').notNull(), // pdf, epub, html
location: text('location').notNull(),
progress: real('progress'),
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.documentId] }),
userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt),
}));

View file

@ -97,3 +97,25 @@ export const userTtsChars = sqliteTable("user_tts_chars", {
pk: primaryKey({ columns: [table.userId, table.date] }),
dateIdx: index('idx_user_tts_chars_date').on(table.date),
}));
export const userPreferences = sqliteTable('user_preferences', {
userId: text('user_id').primaryKey(),
dataJson: text('data_json').notNull().default('{}'),
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
});
export const userDocumentProgress = sqliteTable('user_document_progress', {
userId: text('user_id').notNull(),
documentId: text('document_id').notNull(),
readerType: text('reader_type').notNull(), // pdf, epub, html
location: text('location').notNull(),
progress: real('progress'),
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.documentId] }),
userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt),
}));

View file

@ -0,0 +1,181 @@
import { SYNCED_PREFERENCE_KEYS, type DocumentProgressRecord, type ReaderType, type SyncedPreferencesPatch } from '@/types/user-state';
type PreferencesResponse = {
preferences: SyncedPreferencesPatch;
clientUpdatedAtMs: number;
hasStoredPreferences?: boolean;
};
type ProgressResponse = {
progress: DocumentProgressRecord | null;
};
function sanitizePreferencesPatch(input: SyncedPreferencesPatch): SyncedPreferencesPatch {
const patch: SyncedPreferencesPatch = {};
for (const key of SYNCED_PREFERENCE_KEYS) {
if (!(key in input)) continue;
const value = input[key];
if (value === undefined) continue;
(patch as Record<string, unknown>)[key] = value;
}
return patch;
}
export async function getUserPreferences(options?: { signal?: AbortSignal }): Promise<PreferencesResponse> {
const res = await fetch('/api/user/state/preferences', { signal: options?.signal });
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error || 'Failed to load user preferences');
}
return (await res.json()) as PreferencesResponse;
}
export async function putUserPreferences(
patch: SyncedPreferencesPatch,
options?: { signal?: AbortSignal; clientUpdatedAtMs?: number },
): Promise<PreferencesResponse & { applied: boolean }> {
const cleanPatch = sanitizePreferencesPatch(patch);
const res = await fetch('/api/user/state/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
patch: cleanPatch,
clientUpdatedAtMs: options?.clientUpdatedAtMs ?? Date.now(),
}),
signal: options?.signal,
});
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error || 'Failed to update user preferences');
}
return (await res.json()) as PreferencesResponse & { applied: boolean };
}
type PendingPreferenceSync = {
patch: SyncedPreferencesPatch;
timer: ReturnType<typeof setTimeout> | null;
};
const pendingPreferenceSync: PendingPreferenceSync = {
patch: {},
timer: null,
};
export function scheduleUserPreferencesSync(patch: SyncedPreferencesPatch, debounceMs: number = 600): void {
Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch));
if (pendingPreferenceSync.timer) {
clearTimeout(pendingPreferenceSync.timer);
}
pendingPreferenceSync.timer = setTimeout(async () => {
const payload = { ...pendingPreferenceSync.patch };
pendingPreferenceSync.patch = {};
pendingPreferenceSync.timer = null;
if (Object.keys(payload).length === 0) return;
try {
await putUserPreferences(payload);
} catch (error) {
console.warn('Failed to sync user preferences:', error);
}
}, debounceMs);
}
export async function getDocumentProgress(
documentId: string,
options?: { signal?: AbortSignal },
): Promise<DocumentProgressRecord | null> {
const res = await fetch(`/api/user/state/progress?documentId=${encodeURIComponent(documentId)}`, {
signal: options?.signal,
});
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error || 'Failed to load document progress');
}
const data = (await res.json()) as ProgressResponse;
return data.progress ?? null;
}
export async function putDocumentProgress(payload: {
documentId: string;
readerType: ReaderType;
location: string;
progress?: number | null;
clientUpdatedAtMs?: number;
signal?: AbortSignal;
}): Promise<DocumentProgressRecord | null> {
const res = await fetch('/api/user/state/progress', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId: payload.documentId,
readerType: payload.readerType,
location: payload.location,
progress: payload.progress ?? null,
clientUpdatedAtMs: payload.clientUpdatedAtMs ?? Date.now(),
}),
signal: payload.signal,
});
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error || 'Failed to update document progress');
}
const data = (await res.json()) as ProgressResponse;
return data.progress ?? null;
}
type PendingProgressSync = {
payload: {
documentId: string;
readerType: ReaderType;
location: string;
progress: number | null;
};
timer: ReturnType<typeof setTimeout> | null;
};
const pendingProgressByDoc = new Map<string, PendingProgressSync>();
export function scheduleDocumentProgressSync(
payload: {
documentId: string;
readerType: ReaderType;
location: string;
progress?: number | null;
},
debounceMs: number = 1000,
): void {
const existing = pendingProgressByDoc.get(payload.documentId);
if (existing?.timer) {
clearTimeout(existing.timer);
}
const next: PendingProgressSync = {
payload: {
documentId: payload.documentId,
readerType: payload.readerType,
location: payload.location,
progress: payload.progress ?? null,
},
timer: null,
};
next.timer = setTimeout(async () => {
pendingProgressByDoc.delete(payload.documentId);
try {
await putDocumentProgress({
documentId: next.payload.documentId,
readerType: next.payload.readerType,
location: next.payload.location,
progress: next.payload.progress,
});
} catch (error) {
console.warn('Failed to sync document progress:', error);
}
}, debounceMs);
pendingProgressByDoc.set(payload.documentId, next);
}

View file

@ -7,7 +7,12 @@ import type { NextRequest } from 'next/server';
import { db } from "@/db";
import { rateLimiter } from "@/lib/server/rate-limiter";
import { isAuthEnabled } from "@/lib/server/auth-config";
import { transferUserAudiobooks, transferUserDocuments } from "@/lib/server/claim-data";
import {
transferUserAudiobooks,
transferUserDocuments,
transferUserPreferences,
transferUserProgress,
} from "@/lib/server/claim-data";
import * as schema from "@/db/schema"; // Import the dynamic schema
@ -135,6 +140,28 @@ const createAuth = () => betterAuth({
console.error("Error transferring documents during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer preferences from anonymous user to new authenticated user
try {
const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring preferences during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer reading progress from anonymous user to new authenticated user
try {
const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring reading progress during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
} catch (error) {
console.error("Error in onLinkAccount callback:", error);
// Don't throw here to prevent blocking the account linking process

View file

@ -1,6 +1,6 @@
import { db } from '@/db';
import { documents, audiobooks, audiobookChapters } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { UNCLAIMED_USER_ID } from './docstore';
import {
deleteAudiobookObject,
@ -34,6 +34,25 @@ type AudiobookChapterRow = {
format: string;
};
type UserPreferenceRow = {
userId: string;
dataJson: unknown;
clientUpdatedAtMs: number;
createdAt: unknown;
updatedAt: unknown;
};
type UserDocumentProgressRow = {
userId: string;
documentId: string;
readerType: string;
location: string;
progress: number | null;
clientUpdatedAtMs: number;
createdAt: unknown;
updatedAt: unknown;
};
function contentTypeForAudiobookObject(fileName: string): string {
if (fileName.endsWith('.mp3')) return 'audio/mpeg';
if (fileName.endsWith('.m4b')) return 'audio/mp4';
@ -70,16 +89,22 @@ async function moveAudiobookBlobScope(
}
export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) {
if (!isAuthEnabled() || !userId) return { documents: 0, audiobooks: 0 };
if (!isAuthEnabled() || !userId) {
return { documents: 0, audiobooks: 0, preferences: 0, progress: 0 };
}
const [documentsClaimed, audiobooksClaimed] = await Promise.all([
const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed] = await Promise.all([
transferUserDocuments(unclaimedUserId, userId),
transferUserAudiobooks(unclaimedUserId, userId, namespace),
transferUserPreferences(unclaimedUserId, userId),
transferUserProgress(unclaimedUserId, userId),
]);
return {
documents: documentsClaimed,
audiobooks: audiobooksClaimed,
preferences: preferencesClaimed,
progress: progressClaimed,
};
}
@ -161,3 +186,92 @@ export async function transferUserAudiobooks(
return books.length;
}
export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise<number> {
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0;
const fromRows = (await db
.select()
.from(userPreferences)
.where(eq(userPreferences.userId, fromUserId))) as UserPreferenceRow[];
const fromRow = fromRows[0];
if (!fromRow) return 0;
const toRows = (await db
.select()
.from(userPreferences)
.where(eq(userPreferences.userId, toUserId))) as UserPreferenceRow[];
const toRow = toRows[0];
if (!toRow || Number(fromRow.clientUpdatedAtMs ?? 0) > Number(toRow.clientUpdatedAtMs ?? 0)) {
await db
.insert(userPreferences)
.values({
...fromRow,
userId: toUserId,
})
.onConflictDoUpdate({
target: [userPreferences.userId],
set: {
dataJson: fromRow.dataJson,
clientUpdatedAtMs: fromRow.clientUpdatedAtMs,
updatedAt: fromRow.updatedAt,
},
});
}
await db.delete(userPreferences).where(eq(userPreferences.userId, fromUserId));
return 1;
}
export async function transferUserProgress(fromUserId: string, toUserId: string): Promise<number> {
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0;
const fromRows = (await db
.select()
.from(userDocumentProgress)
.where(eq(userDocumentProgress.userId, fromUserId))) as UserDocumentProgressRow[];
if (fromRows.length === 0) return 0;
const documentIds = fromRows.map((row) => row.documentId);
const toRows = (await db
.select()
.from(userDocumentProgress)
.where(and(
eq(userDocumentProgress.userId, toUserId),
inArray(userDocumentProgress.documentId, documentIds),
))) as UserDocumentProgressRow[];
const toByDocId = new Map<string, UserDocumentProgressRow>();
for (const row of toRows) {
toByDocId.set(row.documentId, row);
}
for (const row of fromRows) {
const existing = toByDocId.get(row.documentId);
const fromUpdated = Number(row.clientUpdatedAtMs ?? 0);
const toUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
if (existing && fromUpdated <= toUpdated) continue;
await db
.insert(userDocumentProgress)
.values({
...row,
userId: toUserId,
})
.onConflictDoUpdate({
target: [userDocumentProgress.userId, userDocumentProgress.documentId],
set: {
readerType: row.readerType,
location: row.location,
progress: row.progress,
clientUpdatedAtMs: row.clientUpdatedAtMs,
updatedAt: row.updatedAt,
},
});
}
await db.delete(userDocumentProgress).where(eq(userDocumentProgress.userId, fromUserId));
return fromRows.length;
}

View file

@ -0,0 +1,30 @@
import type { NextRequest } from 'next/server';
import type { AuthContext } from '@/lib/server/auth';
import { requireAuthContext } from '@/lib/server/auth';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
export type ResolvedUserStateScope = {
auth: AuthContext;
namespace: string | null;
ownerUserId: string;
unclaimedUserId: string;
};
export async function resolveUserStateScope(
req: NextRequest,
): Promise<ResolvedUserStateScope | Response> {
const auth = await requireAuthContext(req);
if (auth instanceof Response) return auth;
const namespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace);
const ownerUserId = auth.userId ?? unclaimedUserId;
return {
auth,
namespace,
ownerUserId,
unclaimedUserId,
};
}

39
src/types/user-state.ts Normal file
View file

@ -0,0 +1,39 @@
import type { AppConfigValues } from '@/types/config';
export const SYNCED_PREFERENCE_KEYS = [
'viewType',
'voiceSpeed',
'audioPlayerSpeed',
'voice',
'skipBlank',
'epubTheme',
'smartSentenceSplitting',
'headerMargin',
'footerMargin',
'leftMargin',
'rightMargin',
'ttsProvider',
'ttsModel',
'ttsInstructions',
'savedVoices',
'pdfHighlightEnabled',
'pdfWordHighlightEnabled',
'epubHighlightEnabled',
'epubWordHighlightEnabled',
] as const;
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];
export type SyncedPreferences = Pick<AppConfigValues, SyncedPreferenceKey>;
export type SyncedPreferencesPatch = Partial<SyncedPreferences>;
export type ReaderType = 'pdf' | 'epub' | 'html';
export interface DocumentProgressRecord {
documentId: string;
readerType: ReaderType;
location: string;
progress: number | null;
clientUpdatedAtMs: number;
updatedAtMs: number;
}