Compare commits

...

1 commit

Author SHA1 Message Date
Richard R
b2bbd8fbef feat(data-storage): migrate all user state from IndexedDB to server-backed storage
Remove all Dexie/IndexedDB code and dependencies, including document and preview caches, local config, onboarding, and migration logic. Replace with server-backed React Query hooks for documents, folders, preferences, onboarding, and progress. Add browser Cache Storage for blob caching of documents, previews, and audio. Update API routes, database schema, and tests to support folder management, onboarding state, and server-side persistence of all user data. Refactor UI and hooks to use server state exclusively, ensuring all user state is synced and portable across devices.

BREAKING CHANGE: All user data, preferences, onboarding, and document state are now stored and synced on the server; browser IndexedDB is no longer used. Existing local-only data will not be available after this update.
2026-06-14 18:13:39 -06:00
93 changed files with 5820 additions and 3539 deletions

View file

@ -41,7 +41,7 @@ When a non-admin user picks a shared provider in **Settings → TTS Provider**:
- The API key / base URL fields are hidden — those credentials never leave the server.
- The TTS request still goes through the user's browser, but the server replaces the slug with the matching admin row's decrypted key and base URL before calling the upstream provider.
- The user's per-request `x-openai-key` / `x-openai-base-url` headers are ignored for shared slugs.
- TTS credentials are resolved only from admin-managed shared providers and are never accepted from client request headers.
Whether users can supply their own personal built-in provider keys is controlled by the site feature `restrictUserApiKeys`:

View file

@ -20,7 +20,7 @@ title: Stack
- Interactions: `react-dnd`, `react-dropzone`
- Server state: [TanStack Query](https://tanstack.com/query) (React Query v5)
- Authentication: [Better Auth](https://www.better-auth.com/) client SDK
- Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB)
- Browser blob performance cache: Cache Storage (evictable; never authoritative)
- Audio playback: [Howler.js](https://howlerjs.com/)
- Notifications: `react-hot-toast`
- Document rendering:

View file

@ -29,7 +29,6 @@ const UI_ARCHITECTURE_FILES = [
"src/components/doclist/**/*.{ts,tsx}",
"src/components/documents/DocumentUploader.tsx",
"src/components/documents/DocumentSelectionModal.tsx",
"src/components/documents/DexieMigrationModal.tsx",
"src/components/documents/ZoomControl.tsx",
"src/components/player/**/*.{ts,tsx}",
"src/components/reader/**/*.{ts,tsx}",

View file

@ -54,8 +54,6 @@
"cmpstr": "^3.3.0",
"compromise": "^14.15.1",
"core-js": "^3.49.0",
"dexie": "^4.4.3",
"dexie-react-hooks": "^4.4.0",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"epubjs": "^0.3.93",
@ -102,8 +100,8 @@
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.4",
"eslint-config-next": "^15.5.19",
"postcss": "^8.5.15",
"openapi-typescript": "^7.10.1",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vitest": "^4.1.8"

View file

@ -19,7 +19,7 @@ export class FakeControlPlane {
private readonly artifactKeys = new Set<string>();
private nextOpId = 1;
readonly deps: ComputeWorkerRouteDeps = {
readonly deps = {
orchestrator: {
enqueueOrReuse: async (request) => this.enqueueOrReuse(request),
markRunning: async (input) => this.updateState(input.opId, {
@ -75,7 +75,7 @@ export class FakeControlPlane {
},
},
artifactExists: async (key) => this.artifactKeys.has(key),
};
} satisfies ComputeWorkerRouteDeps;
seedState(state: ComputeState): void {
this.stateByOpId.set(state.opId, state);

View file

@ -24,8 +24,8 @@ describe('orphan recovery', () => {
});
const firstSweep = await recoverOrphanedOperations({
operationStateStore: fake.deps.operationStateStore!,
orchestrator: fake.deps.orchestrator!,
operationStateStore: fake.deps.operationStateStore,
orchestrator: fake.deps.orchestrator,
whisperTimeoutMs: 30_000,
pdfTimeoutMs: 300_000,
opStaleMs: 1_800_000,
@ -38,8 +38,8 @@ describe('orphan recovery', () => {
vi.advanceTimersByTime(31_000);
const secondSweep = await recoverOrphanedOperations({
operationStateStore: fake.deps.operationStateStore!,
orchestrator: fake.deps.orchestrator!,
operationStateStore: fake.deps.operationStateStore,
orchestrator: fake.deps.orchestrator,
whisperTimeoutMs: 30_000,
pdfTimeoutMs: 300_000,
opStaleMs: 1_800_000,

View file

@ -0,0 +1,26 @@
CREATE TABLE "user_folders" (
"id" text NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "user_folders_id_user_id_pk" PRIMARY KEY("id","user_id")
);
--> statement-breakpoint
CREATE TABLE "user_onboarding" (
"user_id" text PRIMARY KEY NOT NULL,
"privacy_accepted_at_ms" bigint,
"last_seen_app_version" text,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint
);
--> statement-breakpoint
ALTER TABLE "documents" ADD COLUMN "folder_id" text;--> statement-breakpoint
ALTER TABLE "documents" ADD COLUMN "recently_opened_at" bigint;--> statement-breakpoint
ALTER TABLE "user_folders" ADD CONSTRAINT "user_folders_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_user_folders_user_position" ON "user_folders" USING btree ("user_id","position");--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_folder_id_user_id_user_folders_id_user_id_fk" FOREIGN KEY ("folder_id","user_id") REFERENCES "public"."user_folders"("id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_documents_user_id_folder" ON "documents" USING btree ("user_id","folder_id");--> statement-breakpoint
CREATE INDEX "idx_documents_user_id_recently_opened" ON "documents" USING btree ("user_id","recently_opened_at");

File diff suppressed because it is too large Load diff

View file

@ -85,6 +85,13 @@
"when": 1780851107819,
"tag": "0011_scheduled-tasks",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781466220153,
"tag": "0012_user_additions",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,46 @@
CREATE TABLE `user_folders` (
`id` text NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`position` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_user_folders_user_position` ON `user_folders` (`user_id`,`position`);--> statement-breakpoint
CREATE TABLE `user_onboarding` (
`user_id` text PRIMARY KEY NOT NULL,
`privacy_accepted_at_ms` integer,
`last_seen_app_version` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_documents` (
`id` text NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`size` integer NOT NULL,
`last_modified` integer NOT NULL,
`file_path` text NOT NULL,
`folder_id` text,
`recently_opened_at` integer,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`folder_id`,`user_id`) REFERENCES `user_folders`(`id`,`user_id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_documents`("id", "user_id", "name", "type", "size", "last_modified", "file_path", "folder_id", "recently_opened_at", "created_at") SELECT "id", "user_id", "name", "type", "size", "last_modified", "file_path", NULL, NULL, "created_at" FROM `documents`;--> statement-breakpoint
DROP TABLE `documents`;--> statement-breakpoint
ALTER TABLE `__new_documents` RENAME TO `documents`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `idx_documents_user_id` ON `documents` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_documents_user_id_last_modified` ON `documents` (`user_id`,`last_modified`);--> statement-breakpoint
CREATE INDEX `idx_documents_user_id_folder` ON `documents` (`user_id`,`folder_id`);--> statement-breakpoint
CREATE INDEX `idx_documents_user_id_recently_opened` ON `documents` (`user_id`,`recently_opened_at`);

File diff suppressed because it is too large Load diff

View file

@ -85,6 +85,13 @@
"when": 1780851107505,
"tag": "0011_scheduled-tasks",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1781466219864,
"tag": "0012_user_additions",
"breakpoints": true
}
]
}

View file

@ -7,11 +7,13 @@ const usePostgres = !!process.env.POSTGRES_URL;
// and are NOT part of the Drizzle schema. Only app-specific tables are exported here.
export const documents = usePostgres ? postgresSchema.documents : sqliteSchema.documents;
export const userFolders = usePostgres ? postgresSchema.userFolders : sqliteSchema.userFolders;
export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema.audiobooks;
export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters;
export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars;
export const userJobEvents = usePostgres ? postgresSchema.userJobEvents : sqliteSchema.userJobEvents;
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
export const userOnboarding = usePostgres ? postgresSchema.userOnboarding : sqliteSchema.userOnboarding;
export const documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings;
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;
export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews;

View file

@ -4,6 +4,18 @@ import { user } from './schema_auth_postgres';
const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`;
export const userFolders = pgTable('user_folders', {
id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
position: integer('position').notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
index('idx_user_folders_user_position').on(table.userId, table.position),
]);
export const documents = pgTable('documents', {
id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
@ -12,11 +24,19 @@ export const documents = pgTable('documents', {
size: bigint('size', { mode: 'number' }).notNull(),
lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
filePath: text('file_path').notNull(),
folderId: text('folder_id'),
recentlyOpenedAt: bigint('recently_opened_at', { mode: 'number' }),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
foreignKey({
columns: [table.folderId, table.userId],
foreignColumns: [userFolders.id, userFolders.userId],
}),
index('idx_documents_user_id').on(table.userId),
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
index('idx_documents_user_id_folder').on(table.userId, table.folderId),
index('idx_documents_user_id_recently_opened').on(table.userId, table.recentlyOpenedAt),
]);
export const audiobooks = pgTable('audiobooks', {
@ -89,6 +109,14 @@ export const userPreferences = pgTable('user_preferences', {
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
});
export const userOnboarding = pgTable('user_onboarding', {
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
privacyAcceptedAtMs: bigint('privacy_accepted_at_ms', { mode: 'number' }),
lastSeenAppVersion: text('last_seen_app_version'),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
});
export const documentSettings = pgTable('document_settings', {
documentId: text('document_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),

View file

@ -4,6 +4,18 @@ import { user } from './schema_auth_sqlite';
const SQLITE_NOW_MS = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
export const userFolders = sqliteTable('user_folders', {
id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
position: integer('position').notNull().default(0),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
index('idx_user_folders_user_position').on(table.userId, table.position),
]);
export const documents = sqliteTable('documents', {
id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
@ -12,11 +24,19 @@ export const documents = sqliteTable('documents', {
size: integer('size').notNull(),
lastModified: integer('last_modified').notNull(),
filePath: text('file_path').notNull(),
folderId: text('folder_id'),
recentlyOpenedAt: integer('recently_opened_at'),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
foreignKey({
columns: [table.folderId, table.userId],
foreignColumns: [userFolders.id, userFolders.userId],
}),
index('idx_documents_user_id').on(table.userId),
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
index('idx_documents_user_id_folder').on(table.userId, table.folderId),
index('idx_documents_user_id_recently_opened').on(table.userId, table.recentlyOpenedAt),
]);
export const audiobooks = sqliteTable('audiobooks', {
@ -89,6 +109,14 @@ export const userPreferences = sqliteTable('user_preferences', {
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
});
export const userOnboarding = sqliteTable('user_onboarding', {
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
privacyAcceptedAtMs: integer('privacy_accepted_at_ms'),
lastSeenAppVersion: text('last_seen_app_version'),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
});
export const documentSettings = sqliteTable('document_settings', {
documentId: text('document_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),

View file

@ -59,12 +59,6 @@ importers:
core-js:
specifier: ^3.49.0
version: 3.49.0
dexie:
specifier: ^4.4.3
version: 4.4.3
dexie-react-hooks:
specifier: ^4.4.0
version: 4.4.0(dexie@4.4.3)(react@19.2.7)
dotenv:
specifier: ^17.4.2
version: 17.4.2
@ -2606,15 +2600,6 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
dexie-react-hooks@4.4.0:
resolution: {integrity: sha512-ObLXBS5+4BJU8vtSvBx6b9fY6zZYgniAtwxzjCHsUQadgbqYN6935X2/1TWw4Rf2N1aZV1io5/ziox4vKuxABA==}
peerDependencies:
dexie: '>=4.2.0-alpha.1 <5.0.0'
react: '>=16'
dexie@4.4.3:
resolution: {integrity: sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@ -7089,13 +7074,6 @@ snapshots:
dependencies:
dequal: 2.0.3
dexie-react-hooks@4.4.0(dexie@4.4.3)(react@19.2.7):
dependencies:
dexie: 4.4.3
react: 19.2.7
dexie@4.4.3: {}
didyoumean@1.2.2: {}
dlv@1.1.3: {}

View file

@ -1,6 +1,6 @@
'use client';
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { EPUBViewer } from '@/components/views/EPUBViewer';
@ -14,7 +14,6 @@ import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import { resolveDocumentId } from '@/lib/client/dexie';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
@ -26,7 +25,6 @@ import { useEpubDocument } from './useEpubDocument';
export default function EPUBPage() {
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
const { id } = useParams();
const router = useRouter();
const routeDocumentId = typeof id === 'string' ? id : undefined;
const epubState = useEpubDocument(routeDocumentId);
const {
@ -62,19 +60,13 @@ export default function EPUBPage() {
const loadDocument = useCallback(async () => {
console.log('Loading new epub (from page.tsx)');
let didRedirect = false;
let startedLoad = false;
try {
if (!routeDocumentId) {
setError('Document not found');
return;
}
const resolved = await resolveDocumentId(routeDocumentId);
if (resolved !== routeDocumentId) {
didRedirect = true;
router.replace(`/epub/${resolved}`);
return;
}
const resolved = routeDocumentId;
if (loadedDocIdRef.current === resolved) {
return;
@ -95,11 +87,11 @@ export default function EPUBPage() {
if (startedLoad) {
inFlightDocIdRef.current = null;
}
if (!didRedirect && startedLoad) {
if (startedLoad) {
setIsLoading(false);
}
}
}, [routeDocumentId, router, setCurrentDocument, stop]);
}, [routeDocumentId, setCurrentDocument, stop]);
useEffect(() => {
if (!isLoading) return;

View file

@ -115,8 +115,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
} = useTTS();
// Configuration context to get TTS settings
const {
apiKey,
baseUrl,
providerRef,
ttsSegmentMaxBlockLength,
epubTheme,
@ -195,7 +193,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
}, [resetHighlightState, setCurrDocPages, stop]);
/**
* Sets the current document based on its ID by fetching from IndexedDB
* Sets the current document based on its ID using server metadata and the browser blob cache.
* @param {string} id - The unique identifier of the document
* @throws {Error} When document data is empty or retrieval fails
*/
@ -478,8 +476,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({
bookRef,
tocRef,
apiKey,
baseUrl,
providerRef,
});

View file

@ -1,6 +1,6 @@
'use client';
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { HTMLViewer } from '@/components/views/HTMLViewer';
@ -9,7 +9,6 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer';
import { resolveDocumentId } from '@/lib/client/dexie';
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
@ -26,7 +25,6 @@ import { useHtmlDocument } from './useHtmlDocument';
export default function HTMLPage() {
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
const { id } = useParams();
const router = useRouter();
const routeDocumentId = typeof id === 'string' ? id : undefined;
const htmlState = useHtmlDocument();
const {
@ -63,19 +61,13 @@ export default function HTMLPage() {
const loadDocument = useCallback(async () => {
if (!isLoading) return;
console.log('Loading new HTML document (from page.tsx)');
let didRedirect = false;
let startedLoad = false;
try {
if (!id) {
setError('Document not found');
return;
}
const resolved = await resolveDocumentId(id as string);
if (resolved !== (id as string)) {
didRedirect = true;
router.replace(`/html/${resolved}`);
return;
}
const resolved = id as string;
if (loadedDocIdRef.current === resolved) {
return;
@ -96,11 +88,11 @@ export default function HTMLPage() {
if (startedLoad) {
inFlightDocIdRef.current = null;
}
if (!didRedirect && startedLoad) {
if (startedLoad) {
setIsLoading(false);
}
}
}, [isLoading, id, router, setCurrentDocument, stop]);
}, [isLoading, id, setCurrentDocument, stop]);
useEffect(() => {
if (!isLoading) return;

View file

@ -66,8 +66,6 @@ function buildFullDocumentText(blocks: HtmlBlock[]): string {
export function useHtmlDocument(): HtmlDocumentState {
const { setText: setTTSText, stop, setIsEPUB } = useTTS();
const {
apiKey,
baseUrl,
providerRef,
ttsSegmentMaxBlockLength,
} = useConfig();
@ -173,8 +171,6 @@ export function useHtmlDocument(): HtmlDocumentState {
try {
return await runAudiobookGeneration({
adapter: audiobookAdapter,
apiKey,
baseUrl,
defaultProvider: providerRef,
onProgress,
signal,
@ -189,7 +185,7 @@ export function useHtmlDocument(): HtmlDocumentState {
throw error;
}
},
[audiobookAdapter, apiKey, baseUrl, providerRef],
[audiobookAdapter, providerRef],
);
const regenerateChapter = useCallback(
@ -208,8 +204,6 @@ export function useHtmlDocument(): HtmlDocumentState {
bookId,
format,
signal,
apiKey,
baseUrl,
defaultProvider: providerRef,
settings,
retryOptions,
@ -222,7 +216,7 @@ export function useHtmlDocument(): HtmlDocumentState {
throw error;
}
},
[audiobookAdapter, apiKey, baseUrl, providerRef],
[audiobookAdapter, providerRef],
);
return useMemo(

View file

@ -34,12 +34,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
{/* ConfigProvider lives here, in the shared (app) layout, so it stays
mounted across library <-> reader navigation. Mounting it per-route
re-ran the Dexie/server hydration race on every navigation, causing
the reader to briefly use the admin-default provider until a full
page refresh. A single shared instance keeps the user's saved
provider hydrated the whole time. */}
{/* Keep the preferences query/context mounted across library and reader navigation. */}
<ConfigProvider>
<AppShell>
<AppMain>{children}</AppMain>

View file

@ -14,7 +14,6 @@ import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import TTSPlayer from '@/components/player/TTSPlayer';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { resolveDocumentId } from '@/lib/client/dexie';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
@ -99,7 +98,6 @@ export default function PDFViewerPage() {
const loadDocument = useCallback(async () => {
if (!isLoading) return; // Prevent calls when not loading new doc
console.log('Loading new document (from page.tsx)');
let didRedirect = false;
let startedLoad = false;
let loadSucceeded = false;
try {
@ -107,12 +105,7 @@ export default function PDFViewerPage() {
setError('Document not found');
return;
}
const resolved = await resolveDocumentId(id as string);
if (resolved !== (id as string)) {
didRedirect = true;
router.replace(`/pdf/${resolved}`);
return;
}
const resolved = id as string;
if (loadedDocIdRef.current === resolved) {
return;
@ -125,12 +118,18 @@ export default function PDFViewerPage() {
inFlightDocIdRef.current = resolved;
stop(); // Reset TTS when loading new document
for (let attempt = 0; attempt < 2; attempt += 1) {
const loaded = await setCurrentDocument(resolved);
if (loaded) {
const result = await setCurrentDocument(resolved);
if (result === 'loaded') {
loadSucceeded = true;
loadedDocIdRef.current = resolved;
break;
}
if (result === 'superseded') {
// A newer load (or unmount) is now authoritative; it owns the loading
// lifecycle. Bail without surfacing an error to avoid the spurious
// "Failed to load" screen on first launch.
return;
}
if (attempt === 0) {
await new Promise((resolve) => setTimeout(resolve, 250));
}
@ -145,11 +144,11 @@ export default function PDFViewerPage() {
if (startedLoad) {
inFlightDocIdRef.current = null;
}
if (!didRedirect && startedLoad && loadSucceeded) {
if (startedLoad && loadSucceeded) {
setIsLoading(false);
}
}
}, [isLoading, id, router, setCurrentDocument, stop]);
}, [isLoading, id, setCurrentDocument, stop]);
useEffect(() => {
loadDocument();

View file

@ -22,10 +22,8 @@ import {
ensureParsedPdfDocumentOperation,
forceReparsePdfDocument,
getDocumentMetadata,
getDocumentSettings,
getParsedPdfDocument,
ParsedPdfNotReadyError,
putDocumentSettings,
subscribeParsedPdfDocumentEvents,
} from '@/lib/client/api/documents';
import { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf';
@ -56,6 +54,16 @@ import type {
} from '@/types/tts';
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
import { clampSegmentPreloadDepth } from '@/types/config';
import { useDocumentSettings } from '@/hooks/useDocumentSettings';
/**
* Outcome of a `setCurrentDocument` call.
* - `loaded`: the document was fetched and is now the active document.
* - `superseded`: the load was aborted/replaced by a newer load (or unmount).
* A newer load is authoritative; callers must NOT treat this as an error.
* - `failed`: a genuine failure (not found, wrong type, network error).
*/
export type SetCurrentDocumentResult = 'loaded' | 'superseded' | 'failed';
/**
* Interface defining all available methods and properties for the PDF route.
@ -78,7 +86,7 @@ export interface PdfDocumentState {
parsedOverlayEnabled: boolean;
setParsedOverlayEnabled: (enabled: boolean) => void;
forceReparseParsedPdf: () => Promise<void>;
setCurrentDocument: (id: string) => Promise<boolean>;
setCurrentDocument: (id: string) => Promise<SetCurrentDocumentResult>;
clearCurrDoc: () => void;
// PDF functionality
@ -139,8 +147,6 @@ export function usePdfDocument(): PdfDocumentState {
registerVisualPageChangeHandler,
} = useTTS();
const {
apiKey,
baseUrl,
providerRef,
segmentPreloadDepthPages,
ttsSegmentMaxBlockLength,
@ -158,6 +164,11 @@ export function usePdfDocument(): PdfDocumentState {
const [parseProgress, setParseProgress] = useState<PdfParseProgress | null>(null);
const [, setActiveParseOpId] = useState<string | null>(null);
const [documentSettings, setDocumentSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
const serverDocumentSettings = useDocumentSettings(currDocId);
useEffect(() => {
if (!serverDocumentSettings.query.data?.settings) return;
setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, serverDocumentSettings.query.data.settings));
}, [serverDocumentSettings.query.data?.settings]);
useEffect(() => {
setDocumentLanguage(documentSettings.language ?? 'auto');
lastPreparedPlaybackPageRef.current = null;
@ -206,17 +217,6 @@ export function usePdfDocument(): PdfDocumentState {
pageTextCacheRef.current.clear();
}, []);
const fetchDocumentSettings = useCallback(async (documentId: string, signal: AbortSignal): Promise<void> => {
try {
const response = await getDocumentSettings(documentId, { signal });
setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
console.warn('Failed to load document settings, using defaults:', error);
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
}
}, []);
const startParsedEventStream = useCallback((documentId: string, initialOpId: string) => {
parseStreamAbortRef.current?.abort();
parseSseCloseRef.current?.();
@ -516,12 +516,12 @@ export function usePdfDocument(): PdfDocumentState {
/**
* Sets the current document based on its ID
* Retrieves document from IndexedDB
* Retrieves document from server metadata and the browser blob cache.
*
* @param {string} id - The unique identifier of the document to set
* @returns {Promise<void>}
*/
const setCurrentDocument = useCallback(async (id: string): Promise<boolean> => {
const setCurrentDocument = useCallback(async (id: string): Promise<SetCurrentDocumentResult> => {
// --- race-condition guard ---
const seq = ++docLoadSeqRef.current;
docLoadAbortRef.current?.abort();
@ -554,13 +554,12 @@ export function usePdfDocument(): PdfDocumentState {
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
const meta = await getDocumentMetadata(id, { signal: controller.signal });
if (seq !== docLoadSeqRef.current) return false; // stale
if (seq !== docLoadSeqRef.current) return 'superseded'; // a newer load took over
if (!meta) {
console.error('Document not found on server');
return false;
return 'failed';
}
if (meta.type === 'pdf') {
void fetchDocumentSettings(id, controller.signal);
void resolveParsedDocumentState(id, controller.signal).catch((error) => {
if (controller.signal.aborted) return;
console.error('Failed to resolve parsed PDF state:', error);
@ -568,28 +567,29 @@ export function usePdfDocument(): PdfDocumentState {
}
const doc = await ensureCachedDocument(meta, { signal: controller.signal });
if (seq !== docLoadSeqRef.current) return false; // stale
if (seq !== docLoadSeqRef.current) return 'superseded'; // a newer load took over
if (doc.type !== 'pdf') {
console.error('Document is not a PDF');
return false;
return 'failed';
}
setCurrDocName(doc.name);
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
// buffer passed into the worker; we always pass clones to react-pdf.
setCurrDocData(doc.data.slice(0));
return true;
return 'loaded';
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return false;
// An aborted load means a newer selection (or unmount) took over; not a failure.
if (error instanceof DOMException && error.name === 'AbortError') return 'superseded';
if (controller.signal.aborted) return 'superseded';
console.error('Failed to get document:', error);
return false;
return 'failed';
} finally {
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
if (docLoadAbortRef.current === controller) {
docLoadAbortRef.current = null;
}
}
return false;
}, [
setCurrDocId,
setCurrDocName,
@ -597,7 +597,6 @@ export function usePdfDocument(): PdfDocumentState {
setCurrDocPages,
setCurrDocText,
setPdfDocument,
fetchDocumentSettings,
resolveParsedDocumentState,
]);
@ -605,12 +604,11 @@ export function usePdfDocument(): PdfDocumentState {
if (!currDocId) return;
setDocumentSettings(settings);
try {
const updated = await putDocumentSettings(currDocId, settings);
setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, updated.settings));
await serverDocumentSettings.mutation.mutateAsync(settings);
} catch (error) {
console.warn('Failed to persist document settings:', error);
}
}, [currDocId]);
}, [currDocId, serverDocumentSettings.mutation]);
const forceReparseParsedPdf = useCallback(async (): Promise<void> => {
if (!currDocId) return;
@ -684,8 +682,6 @@ export function usePdfDocument(): PdfDocumentState {
try {
return await runAudiobookGeneration({
adapter: audiobookAdapter,
apiKey,
baseUrl,
defaultProvider: providerRef,
onProgress,
signal,
@ -698,7 +694,7 @@ export function usePdfDocument(): PdfDocumentState {
console.error('Error creating audiobook:', error);
throw error;
}
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
}, [audiobookAdapter, providerRef]);
/**
* Regenerates a specific chapter (page) of the PDF audiobook
@ -717,8 +713,6 @@ export function usePdfDocument(): PdfDocumentState {
bookId,
format,
signal,
apiKey,
baseUrl,
defaultProvider: providerRef,
settings,
});
@ -729,7 +723,7 @@ export function usePdfDocument(): PdfDocumentState {
console.error('Error regenerating page:', error);
throw error;
}
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
}, [audiobookAdapter, providerRef]);
/**
* Effect hook to initialize TTS as non-EPUB mode

View file

@ -477,10 +477,7 @@ export async function POST(request: NextRequest) {
providerForError = requestedProvider;
const credResolved = await resolveTtsCredentials({
providerHeader: requestedProvider,
apiKeyHeader: request.headers.get('x-openai-key'),
baseUrlHeader: request.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
});
if ('error' in credResolved) {
if (credResolved.error === 'no_shared_provider_configured') {

View file

@ -0,0 +1,27 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@openreader/database';
import { documents } from '@openreader/database/schema';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { nowTimestampMs } from '@/lib/shared/timestamps';
import { errorResponse } from '@/lib/server/errors/next-response';
export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const { id } = await ctx.params;
const recentlyOpenedAt = nowTimestampMs();
const rows = await db.update(documents).set({ recentlyOpenedAt }).where(and(
eq(documents.userId, scope.ownerUserId),
eq(documents.id, id.toLowerCase()),
)).returning({ id: documents.id });
if (!rows[0]) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ documentId: rows[0].id, recentlyOpenedAt });
} catch (error) {
return errorResponse(error, {
apiErrorMessage: 'Failed to update recently opened state',
normalize: { code: 'DOCUMENT_OPENED_UPDATE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -45,6 +45,7 @@ export async function GET(req: NextRequest) {
status: 'ready',
presignUrl,
fallbackUrl,
previewVersion: preview.eTag || String(doc.lastModified),
...(directUrl ? { directUrl } : {}),
},
{

View file

@ -0,0 +1,43 @@
import { and, eq, inArray } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@openreader/database';
import { documents, userFolders } from '@openreader/database/schema';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { errorResponse } from '@/lib/server/errors/next-response';
export async function PATCH(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const body = (await req.json().catch(() => null)) as { documentIds?: unknown; folderId?: unknown } | null;
const documentIds = Array.isArray(body?.documentIds)
? Array.from(new Set(body.documentIds.filter((id): id is string => typeof id === 'string')))
: [];
const folderId = body?.folderId === null ? null : typeof body?.folderId === 'string' ? body.folderId : undefined;
if (documentIds.length === 0 || folderId === undefined) {
return NextResponse.json({ error: 'documentIds and folderId are required' }, { status: 400 });
}
const owned = await db.select({ id: documents.id }).from(documents).where(and(
eq(documents.userId, scope.ownerUserId),
inArray(documents.id, documentIds),
));
if (owned.length !== documentIds.length) return NextResponse.json({ error: 'One or more documents were not found' }, { status: 404 });
if (folderId) {
const folder = await db.select({ id: userFolders.id }).from(userFolders).where(and(
eq(userFolders.userId, scope.ownerUserId),
eq(userFolders.id, folderId),
)).limit(1);
if (!folder[0]) return NextResponse.json({ error: 'Folder not found' }, { status: 404 });
}
await db.update(documents).set({ folderId }).where(and(
eq(documents.userId, scope.ownerUserId),
inArray(documents.id, documentIds),
));
return NextResponse.json({ success: true, documentIds, folderId });
} catch (error) {
return errorResponse(error, {
apiErrorMessage: 'Failed to move documents',
normalize: { code: 'DOCUMENT_FOLDER_UPDATE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -64,6 +64,8 @@ export async function GET(req: NextRequest) {
size: number;
lastModified: number;
filePath: string;
folderId: string | null;
recentlyOpenedAt: number | null;
}>;
const results: BaseDocument[] = rows.map((doc) => {
@ -75,6 +77,9 @@ export async function GET(req: NextRequest) {
lastModified: Number(doc.lastModified),
type,
scope: 'user',
folderId: doc.folderId ?? undefined,
recentlyOpenedAt: doc.recentlyOpenedAt == null ? undefined : Number(doc.recentlyOpenedAt),
contentVersion: doc.id,
};
});

View file

@ -0,0 +1,52 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@openreader/database';
import { documents, userFolders } from '@openreader/database/schema';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { nowTimestampMs } from '@/lib/shared/timestamps';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const { id } = await ctx.params;
const body = (await req.json().catch(() => null)) as { name?: unknown; position?: unknown } | null;
const name = typeof body?.name === 'string' ? body.name.trim().slice(0, 200) : undefined;
const position = Number.isFinite(body?.position) ? Number(body?.position) : undefined;
if (!name && position === undefined) return NextResponse.json({ error: 'No valid fields provided' }, { status: 400 });
const rows = await db.update(userFolders).set({
...(name ? { name } : {}),
...(position !== undefined ? { position } : {}),
updatedAt: nowTimestampMs(),
}).where(and(eq(userFolders.id, id), eq(userFolders.userId, scope.ownerUserId))).returning();
if (!rows[0]) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ folder: rows[0] });
} catch (error) {
return errorResponse(error, {
apiErrorMessage: 'Failed to update folder',
normalize: { code: 'FOLDER_UPDATE_FAILED', errorClass: 'db' },
});
}
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const { id } = await ctx.params;
await db.update(documents).set({ folderId: null }).where(and(
eq(documents.userId, scope.ownerUserId),
eq(documents.folderId, id),
));
await db.delete(userFolders).where(and(eq(userFolders.id, id), eq(userFolders.userId, scope.ownerUserId)));
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error, {
apiErrorMessage: 'Failed to delete folder',
normalize: { code: 'FOLDER_DELETE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -0,0 +1,81 @@
import { and, asc, eq, inArray } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@openreader/database';
import { documents, userFolders } from '@openreader/database/schema';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { nowTimestampMs } from '@/lib/shared/timestamps';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const rows = await db.select().from(userFolders)
.where(eq(userFolders.userId, scope.ownerUserId))
.orderBy(asc(userFolders.position), asc(userFolders.createdAt));
return NextResponse.json({ folders: rows });
} catch (error) {
return errorResponse(error, {
apiErrorMessage: 'Failed to load folders',
normalize: { code: 'FOLDERS_LOAD_FAILED', errorClass: 'db' },
});
}
}
export async function POST(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const body = (await req.json().catch(() => null)) as { id?: unknown; name?: unknown; documentIds?: unknown } | null;
const name = typeof body?.name === 'string' ? body.name.trim().slice(0, 200) : '';
if (!name) return NextResponse.json({ error: 'Folder name is required' }, { status: 400 });
const documentIds = Array.isArray(body?.documentIds)
? Array.from(new Set(body.documentIds.filter((id): id is string => typeof id === 'string')))
: [];
if (documentIds.length > 0) {
const owned = await db.select({ id: documents.id }).from(documents).where(and(
eq(documents.userId, scope.ownerUserId),
inArray(documents.id, documentIds),
));
if (owned.length !== documentIds.length) {
return NextResponse.json({ error: 'One or more documents were not found' }, { status: 404 });
}
}
const id = typeof body?.id === 'string' && /^[a-zA-Z0-9-]{1,100}$/.test(body.id)
? body.id
: crypto.randomUUID();
const now = nowTimestampMs();
await db.transaction(async (tx: typeof db) => {
await tx.insert(userFolders).values({ id, userId: scope.ownerUserId, name, position: now, createdAt: now, updatedAt: now });
if (documentIds.length > 0) {
await tx.update(documents).set({ folderId: id }).where(and(
eq(documents.userId, scope.ownerUserId),
inArray(documents.id, documentIds),
));
}
});
return NextResponse.json({ folder: { id, userId: scope.ownerUserId, name, position: now, createdAt: now, updatedAt: now } });
} catch (error) {
return errorResponse(error, {
apiErrorMessage: 'Failed to create folder',
normalize: { code: 'FOLDER_CREATE_FAILED', errorClass: 'db' },
});
}
}
export async function DELETE(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
await db.update(documents).set({ folderId: null }).where(eq(documents.userId, scope.ownerUserId));
await db.delete(userFolders).where(eq(userFolders.userId, scope.ownerUserId));
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error, {
apiErrorMessage: 'Failed to delete folders',
normalize: { code: 'FOLDERS_DELETE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -183,10 +183,7 @@ export async function POST(request: NextRequest) {
});
const requestCreds = await resolveTtsCredentials({
providerHeader: parsed.settings.providerRef,
apiKeyHeader: request.headers.get('x-openai-key'),
baseUrlHeader: request.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
});
if ('error' in requestCreds) {
const status = requestCreds.error === 'no_shared_provider_configured' ? 503 : 404;

View file

@ -20,10 +20,7 @@ export async function GET(req: NextRequest) {
const runtimeConfig = await getResolvedRuntimeConfig();
const resolved = await resolveTtsCredentials({
providerHeader: req.headers.get('x-tts-provider'),
apiKeyHeader: req.headers.get('x-openai-key'),
baseUrlHeader: req.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
});
if ('error' in resolved) {

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { claimAnonymousData } from '@/lib/server/user/claim-data';
import { auth } from '@/lib/server/auth/auth';
import { db } from '@openreader/database';
import { audiobooks, documentSettings, documents, userDocumentProgress, userPreferences } from '@openreader/database/schema';
import { audiobooks, documentSettings, documents, userDocumentProgress, userFolders, userOnboarding, userPreferences } from '@openreader/database/schema';
import { count, eq, ne } from 'drizzle-orm';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { errorToLog, serverLogger } from '@/lib/server/logger';
@ -26,14 +26,16 @@ async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
async function getClaimableCounts(
unclaimedUserId: string,
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number }> {
const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount]] =
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number; folders: number; onboarding: number }> {
const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount], [folderCount], [onboardingCount]] =
await Promise.all([
db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)),
db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)),
db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)),
db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)),
db.select({ count: count() }).from(documentSettings).where(eq(documentSettings.userId, unclaimedUserId)),
db.select({ count: count() }).from(userFolders).where(eq(userFolders.userId, unclaimedUserId)),
db.select({ count: count() }).from(userOnboarding).where(eq(userOnboarding.userId, unclaimedUserId)),
]);
return {
@ -42,6 +44,8 @@ async function getClaimableCounts(
preferences: Number(preferencesCount?.count ?? 0),
progress: Number(progressCount?.count ?? 0),
documentSettings: Number(settingsCount?.count ?? 0),
folders: Number(folderCount?.count ?? 0),
onboarding: Number(onboardingCount?.count ?? 0),
};
}

View file

@ -12,6 +12,8 @@ import {
userDocumentProgress,
userJobEvents,
userPreferences,
userFolders,
userOnboarding,
userTtsChars,
} from '@openreader/database/schema';
import * as authSchemaSqlite from '@openreader/database/schema-auth-sqlite';
@ -65,6 +67,8 @@ export async function GET(req: NextRequest) {
segmentVariants,
userDocs,
userAudiobooks,
folders,
onboarding,
] = await Promise.all([
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1),
db
@ -107,6 +111,8 @@ export async function GET(req: NextRequest) {
.from(audiobooks)
.where(eq(audiobooks.userId, userId))
.orderBy(desc(audiobooks.createdAt)),
db.select().from(userFolders).where(eq(userFolders.userId, userId)).orderBy(userFolders.position),
db.select().from(userOnboarding).where(eq(userOnboarding.userId, userId)).limit(1),
]);
const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
@ -190,6 +196,8 @@ export async function GET(req: NextRequest) {
exportedAtMs,
profileData,
preferences: prefs[0] ?? null,
folders,
onboarding: onboarding[0] ?? null,
readingHistory: progress,
ttsUsage,
jobEvents,

View file

@ -1,26 +1,15 @@
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@openreader/database';
import { userPreferences } from '@openreader/database/schema';
import { userOnboarding } from '@openreader/database/schema';
import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog';
import { nowTimestampMs } from '@/lib/shared/timestamps';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import {
deserializeUserPreferencesPayload,
extractUserPreferencesMeta,
withUserPreferencesMeta,
USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY,
} from '@/lib/server/user/preferences-payload';
export const dynamic = 'force-dynamic';
function serializePreferencesForDb(payload: Record<string, unknown>): Record<string, unknown> | string {
if (process.env.POSTGRES_URL) return payload;
return JSON.stringify(payload);
}
export async function POST(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
@ -38,40 +27,28 @@ export async function POST(req: NextRequest) {
const rows = await db
.select({
dataJson: userPreferences.dataJson,
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
lastSeenAppVersion: userOnboarding.lastSeenAppVersion,
})
.from(userPreferences)
.where(eq(userPreferences.userId, scope.ownerUserId))
.from(userOnboarding)
.where(eq(userOnboarding.userId, scope.ownerUserId))
.limit(1);
const row = rows[0];
const existingPayload = deserializeUserPreferencesPayload(row?.dataJson);
const existingMeta = extractUserPreferencesMeta(existingPayload);
const lastSeenVersion = typeof existingMeta.lastSeenAppVersion === 'string'
? existingMeta.lastSeenAppVersion
: null;
const lastSeenVersion = row?.lastSeenAppVersion ?? null;
const shouldOpen = shouldOpenChangelogForVersionChange(lastSeenVersion, currentVersion);
const nextMeta = {
...existingMeta,
[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY]: currentVersion,
};
const dataJson = serializePreferencesForDb(withUserPreferencesMeta(existingPayload, nextMeta));
const updatedAt = nowTimestampMs();
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
await db
.insert(userPreferences)
.insert(userOnboarding)
.values({
userId: scope.ownerUserId,
dataJson,
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
lastSeenAppVersion: currentVersion,
updatedAt,
})
.onConflictDoUpdate({
target: [userPreferences.userId],
target: [userOnboarding.userId],
set: {
dataJson,
lastSeenAppVersion: currentVersion,
updatedAt,
},
});

View file

@ -0,0 +1,78 @@
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@openreader/database';
import { userOnboarding } from '@openreader/database/schema';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { nowTimestampMs } from '@/lib/shared/timestamps';
import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const rows = await db.select().from(userOnboarding)
.where(eq(userOnboarding.userId, scope.ownerUserId)).limit(1);
const row = rows[0];
return NextResponse.json({
onboarding: {
privacyAcceptedAtMs: row?.privacyAcceptedAtMs == null ? null : Number(row.privacyAcceptedAtMs),
lastSeenAppVersion: row?.lastSeenAppVersion ?? null,
},
});
} catch (error) {
return errorResponse(error, {
apiErrorMessage: 'Failed to load onboarding state',
normalize: { code: 'USER_ONBOARDING_LOAD_FAILED', errorClass: 'db' },
});
}
}
export async function PATCH(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const body = (await req.json().catch(() => null)) as {
privacyAccepted?: unknown;
lastSeenAppVersion?: unknown;
} | null;
const now = nowTimestampMs();
const privacyAcceptedAtMs = body?.privacyAccepted === true
? now
: body?.privacyAccepted === false ? null : undefined;
const lastSeenAppVersion = typeof body?.lastSeenAppVersion === 'string'
? body.lastSeenAppVersion.trim() || null
: undefined;
if (privacyAcceptedAtMs === undefined && lastSeenAppVersion === undefined) {
return NextResponse.json({ error: 'No valid onboarding fields provided' }, { status: 400 });
}
await db.insert(userOnboarding).values({
userId: scope.ownerUserId,
privacyAcceptedAtMs: privacyAcceptedAtMs ?? null,
lastSeenAppVersion: lastSeenAppVersion ?? null,
updatedAt: now,
}).onConflictDoUpdate({
target: [userOnboarding.userId],
set: {
...(privacyAcceptedAtMs !== undefined ? { privacyAcceptedAtMs } : {}),
...(lastSeenAppVersion !== undefined ? { lastSeenAppVersion } : {}),
updatedAt: now,
},
});
const rows = await db.select().from(userOnboarding)
.where(eq(userOnboarding.userId, scope.ownerUserId)).limit(1);
const row = rows[0];
return NextResponse.json({
onboarding: {
privacyAcceptedAtMs: row?.privacyAcceptedAtMs == null ? null : Number(row.privacyAcceptedAtMs),
lastSeenAppVersion: row?.lastSeenAppVersion ?? null,
},
});
} catch (error) {
return errorResponse(error, {
apiErrorMessage: 'Failed to update onboarding state',
normalize: { code: 'USER_ONBOARDING_UPDATE_FAILED', errorClass: 'db' },
});
}
}

View file

@ -13,12 +13,6 @@ import {
sanitizePreferencesPatch,
type PreferenceNormalizationContext,
} from '@/lib/server/user/preferences-normalize';
import {
deserializeUserPreferencesPayload,
extractUserPreferencesMeta,
withUserPreferencesMeta,
type UserPreferencesMeta,
} from '@/lib/server/user/preferences-payload';
export const dynamic = 'force-dynamic';
@ -27,6 +21,22 @@ function serializePreferencesForDb(payload: Record<string, unknown>): Record<str
return JSON.stringify(payload);
}
function deserializePreferences(value: unknown): Record<string, unknown> {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
} catch {
return {};
}
}
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
const [runtimeConfig, providers] = await Promise.all([
getResolvedRuntimeConfig(),
@ -49,11 +59,10 @@ async function loadPreferenceNormalizationContext(): Promise<PreferenceNormaliza
function parseStoredPreferences(
value: unknown,
context: PreferenceNormalizationContext,
): { patch: SyncedPreferencesPatch; migrated: boolean; meta: UserPreferencesMeta } {
const payload = deserializeUserPreferencesPayload(value);
const meta = extractUserPreferencesMeta(payload);
): { patch: SyncedPreferencesPatch; migrated: boolean } {
const payload = deserializePreferences(value);
const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true });
return { ...sanitized, meta };
return { ...sanitized, migrated: sanitized.migrated || '_meta' in payload };
}
function normalizeClientUpdatedAtMs(value: unknown): number {
@ -80,7 +89,6 @@ export async function GET(req: NextRequest) {
const row = rows[0];
const stored = parseStoredPreferences(row?.dataJson, normalizationContext);
const storedPatch = stored.patch;
const storedPayload = withUserPreferencesMeta(storedPatch, stored.meta);
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
if (row && stored.migrated) {
@ -89,14 +97,14 @@ export async function GET(req: NextRequest) {
.insert(userPreferences)
.values({
userId: scope.ownerUserId,
dataJson: serializePreferencesForDb(storedPayload),
dataJson: serializePreferencesForDb(storedPatch),
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
updatedAt,
})
.onConflictDoUpdate({
target: [userPreferences.userId],
set: {
dataJson: serializePreferencesForDb(storedPayload),
dataJson: serializePreferencesForDb(storedPatch),
updatedAt,
},
});
@ -161,8 +169,7 @@ export async function PUT(req: NextRequest) {
}
const mergedPatch = { ...existingPatch, ...patch };
const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta);
const dataJson = serializePreferencesForDb(payloadWithMeta);
const dataJson = serializePreferencesForDb(mergedPatch);
const updatedAt = nowTimestampMs();
await db

View file

@ -1,6 +1,6 @@
'use client';
import { ReactNode, useState } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/contexts/ThemeContext';
@ -25,6 +25,15 @@ export function Providers({ children, authBaseUrl, allowAnonymousAuthSessions, g
},
}));
useEffect(() => {
if (typeof indexedDB === 'undefined') return;
try {
indexedDB.deleteDatabase('openreader-db');
} catch {
// Legacy IndexedDB cleanup is best effort and never blocks startup.
}
}, []);
return (
<QueryClientProvider client={queryClient}>
<RuntimeConfigProvider>

View file

@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { updateAppConfig } from '@/lib/client/dexie';
import { useOnboardingState } from '@/hooks/useOnboardingState';
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
interface PrivacyModalProps {
@ -47,6 +47,7 @@ function PrivacyModalBody({ origin }: { origin: string }) {
}
export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) {
const { mutation } = useOnboardingState();
const [origin, setOrigin] = useState('');
const [agreed, setAgreed] = useState(false);
@ -62,7 +63,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
}, [isOpen]);
const handleAccept = async () => {
await updateAppConfig({ privacyAccepted: true });
await mutation.mutateAsync({ privacyAccepted: true });
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('openreader:privacyAccepted'));
}

View file

@ -22,13 +22,8 @@ import { mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import {
isBuiltInTtsProviderId,
type TtsProviderType,
} from '@/lib/shared/tts-provider-catalog';
import {
defaultBaseUrlForProviderType,
defaultModelForProviderType,
resolveProviderDefaults,
resolveEffectiveProviderType,
resolveTtsProviderModelPolicy,
@ -38,8 +33,6 @@ import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { AdminTasksPanel } from '@/components/admin/AdminTasksPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders';
import { flushUserPreferencesSync } from '@/lib/client/api/user-state';
import toast from 'react-hot-toast';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import {
SidebarDialog,
@ -219,7 +212,6 @@ export function SettingsModal({
const runtimeConfig = useRuntimeConfig();
const showAllProviderModels = runtimeConfig.showAllProviderModels;
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
const isOpen = open;
const setIsOpen = onOpenChange;
const [isChangelogOpen, setIsChangelogOpen] = useState(false);
@ -228,10 +220,8 @@ export function SettingsModal({
const { theme, setTheme, applyCustomColors } = useTheme();
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
const [isCustomExpanded, setIsCustomExpanded] = useState(false);
const { apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
const { providerRef, providerType, ttsModel, ttsInstructions, updateConfigKey } = useConfig();
const { refreshDocuments } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
const [localProviderRef, setLocalProviderRef] = useState(providerRef);
const [localProviderType, setLocalProviderType] = useState<TtsProviderType>(providerType);
const [modelValue, setModelValue] = useState(ttsModel);
@ -279,13 +269,12 @@ export function SettingsModal({
} = useMemo(() => resolveTtsSettingsViewModel({
providerRef: localProviderRef,
providerType: localProviderType,
apiKey: localApiKey,
modelValue,
customModelInput,
showAllProviderModels,
sharedProviders,
allowBuiltInProviders: !restrictUserApiKeys,
}), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllProviderModels, sharedProviders, restrictUserApiKeys]);
allowBuiltInProviders: false,
}), [localProviderRef, localProviderType, modelValue, customModelInput, showAllProviderModels, sharedProviders]);
const isSharedSelected = Boolean(selectedSharedProvider);
const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0];
@ -301,13 +290,11 @@ export function SettingsModal({
// focus refetch in ConfigContext, or async shared-provider loading) must not
// stomp their in-progress selection. On close, resetToCurrent re-syncs.
if (isOpen) return;
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
setLocalProviderRef(providerRef);
setLocalProviderType(providerType);
setModelValue(ttsModel);
setLocalTTSInstructions(ttsInstructions);
}, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
}, [isOpen, providerRef, providerType, ttsModel, ttsInstructions]);
useEffect(() => {
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
@ -331,17 +318,9 @@ export function SettingsModal({
setModelValue(shared.defaultModel);
}
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
setLocalApiKey('');
setLocalBaseUrl('');
setCustomModelInput('');
return;
}
if (isBuiltInTtsProviderId(fallback.providerType)) {
setModelValue(defaultModelForProviderType(fallback.providerType));
setLocalBaseUrl(defaultBaseUrlForProviderType(fallback.providerType));
setCustomModelInput('');
}
}, [selectedProviderOption, ttsProviders, sharedProviders]);
const handleRefresh = async () => {
@ -459,19 +438,9 @@ export function SettingsModal({
setShowDeleteAccountConfirm(false);
};
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
if (type === 'apiKey') {
setLocalApiKey(value === '' ? '' : value);
} else if (type === 'baseUrl') {
setLocalBaseUrl(value === '' ? '' : value);
}
};
const resetToCurrent = useCallback(() => {
setIsOpen(false);
setIsChangelogOpen(false);
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
setLocalProviderRef(providerRef);
setLocalProviderType(providerType);
setModelValue(ttsModel);
@ -481,7 +450,7 @@ export function SettingsModal({
} else {
setCustomModelInput('');
}
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
}, [providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
const [systemIsDark, setSystemIsDark] = useState(
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
@ -546,13 +515,6 @@ export function SettingsModal({
model: modelValue,
sharedProviders,
});
const shouldShowBaseUrl = !restrictUserApiKeys
&& !isSharedSelected
&& providerModelPolicy.isResolvedProviderType
&& providerModelPolicy.providerType !== 'replicate'
&& providerModelPolicy.providerType !== 'speech-sdk'
&& (providerModelPolicy.providerType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected;
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
const selectedModelVersion = selectedModel?.id?.includes(':')
? selectedModel.id.slice(selectedModel.id.indexOf(':'))
@ -642,53 +604,11 @@ export function SettingsModal({
setLocalProviderType(defaults.providerType);
setModelValue(defaults.defaultModel);
setLocalTTSInstructions(defaults.defaultInstructions);
if (provider.shared) {
// Shared admin provider — credentials live on the server.
setLocalApiKey('');
setLocalBaseUrl('');
} else if (isBuiltInTtsProviderId(provider.providerType)) {
setLocalBaseUrl(defaultBaseUrlForProviderType(provider.providerType));
}
setCustomModelInput('');
}}
/>
)}
</div>
{restrictUserApiKeys && (
<p className="text-xs text-soft">
This instance restricts user API keys. TTS runs through admin-configured shared providers only.
</p>
)}
{shouldShowBaseUrl && (
<div className="space-y-1.5">
<label className={fieldLabelClass}>
API Base URL
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<Input
type="text"
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
/>
</div>
)}
{shouldShowApiKey && (
<div className="space-y-1.5">
<label className={fieldLabelClass}>
API Key
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<Input
type="password"
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder="Using environment variable"
/>
</div>
)}
{isSharedSelected && (
<p className="text-xs text-soft">
This is a shared provider configured by an admin. API key and base URL are managed server-side.
@ -773,8 +693,6 @@ export function SettingsModal({
providerRef: runtimeConfig.defaultTtsProvider,
sharedProviders,
});
setLocalApiKey('');
setLocalBaseUrl('');
setLocalProviderRef(defaults.providerRef);
setLocalProviderType(defaults.providerType);
setModelValue(defaults.defaultModel);
@ -796,10 +714,6 @@ export function SettingsModal({
providerType: selectedProviderType,
sharedProviders,
});
await updateConfig({
apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
});
await updateConfigKey('providerRef', selectedProviderRef);
await updateConfigKey('providerType', selectedProviderType);
const finalModel = showAllProviderModels
@ -807,16 +721,6 @@ export function SettingsModal({
: defaults.defaultModel;
await updateConfigKey('ttsModel', finalModel);
await updateConfigKey('ttsInstructions', localTTSInstructions);
// Push the change to the server immediately rather than waiting on
// the debounce, so a quick reload can't lose the save. Surface
// failures instead of silently dropping them.
try {
await flushUserPreferencesSync();
} catch (error) {
console.error('Failed to save TTS provider settings:', error);
toast.error('Failed to save provider settings. Please try again.');
return;
}
setIsOpen(false);
}}
>

View file

@ -11,6 +11,8 @@ export type ClaimableCounts = {
preferences: number;
progress: number;
documentSettings: number;
folders: number;
onboarding: number;
};
function toClaimableCounts(value: unknown): ClaimableCounts {
@ -21,6 +23,8 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
preferences: Number(rec.preferences ?? 0),
progress: Number(rec.progress ?? 0),
documentSettings: Number(rec.documentSettings ?? 0),
folders: Number(rec.folders ?? 0),
onboarding: Number(rec.onboarding ?? 0),
};
}
@ -86,6 +90,8 @@ export default function ClaimDataModal({
<li>{claimableCounts.preferences} preference set(s)</li>
<li>{claimableCounts.progress} reading progress record(s)</li>
<li>{claimableCounts.documentSettings} document setting(s)</li>
<li>{claimableCounts.folders} folder(s)</li>
<li>{claimableCounts.onboarding} onboarding state record(s)</li>
</ul>
</div>

View file

@ -1,7 +1,6 @@
'use client';
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useDocuments } from '@/contexts/DocumentContext';
import type {
DocumentListDocument,
@ -13,11 +12,9 @@ import type {
SortDirection,
ViewMode,
} from '@/types/documents';
import {
getDocumentListState,
getDocumentRecentlyOpenedMap,
saveDocumentListState,
} from '@/lib/client/dexie';
import { useFolders } from '@/hooks/useFolders';
import { useUserPreferences } from '@/hooks/useUserPreferences';
import { useAuthSession } from '@/hooks/useAuthSession';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
@ -202,7 +199,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
normalizeViewMode(cachedState?.viewMode ?? DEFAULT_STATE.viewMode),
);
const [iconSize, setIconSize] = useState<IconSize>(cachedState?.iconSize ?? DEFAULT_STATE.iconSize);
const [folders, setFolders] = useState<Folder[]>(cachedState?.folders ?? DEFAULT_STATE.folders);
const [folders, setFolders] = useState<Folder[]>([]);
const [showHint, setShowHint] = useState(cachedState?.showHint ?? true);
const [sidebarWidth, setSidebarWidth] = useState(cachedState?.sidebarWidth ?? DEFAULT_STATE.sidebarWidth);
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>(cachedState?.sidebarFilter ?? 'all');
@ -213,6 +210,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [isInitialized, setIsInitialized] = useState(cachedState !== null);
const preferenceWriteTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
const [pendingMerge, setPendingMerge] = useState<
@ -235,18 +233,20 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
deleteDocument,
isHTMLLoading,
} = useDocuments();
const { data: session, isPending: isSessionPending } = useAuthSession();
const sessionId = session?.user?.id ?? 'no-session';
const { query: preferencesQuery, mutation: preferencesMutation } = useUserPreferences(sessionId, !isSessionPending);
const persistPreferences = preferencesMutation.mutate;
const folderState = useFolders();
// Load saved state.
useEffect(() => {
let cancelled = false;
(async () => {
const saved = await getDocumentListState();
if (cancelled) return;
if (preferencesQuery.isPending) return;
const saved = preferencesQuery.data?.preferences.documentListState;
if (saved) {
cachedDocumentListState = saved;
setSortBy(saved.sortBy);
setSortDirection(saved.sortDirection);
setFolders(saved.folders ?? []);
setShowHint(saved.showHint ?? true);
setViewMode(normalizeViewMode(saved.viewMode));
setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize);
@ -257,7 +257,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
cachedDocumentListState = null;
setSortBy(DEFAULT_STATE.sortBy);
setSortDirection(DEFAULT_STATE.sortDirection);
setFolders(DEFAULT_STATE.folders);
setShowHint(DEFAULT_STATE.showHint);
setViewMode(DEFAULT_STATE.viewMode);
setIconSize(DEFAULT_STATE.iconSize);
@ -266,11 +265,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
setSidebarOpen(!DEFAULT_STATE.sidebarCollapsed);
}
setIsInitialized(true);
})();
return () => {
cancelled = true;
};
}, []);
}, [preferencesQuery.data?.preferences.documentListState, preferencesQuery.isPending]);
// Persist.
useEffect(() => {
@ -278,7 +273,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const state: DocumentListState = {
sortBy,
sortDirection,
folders,
folders: [],
collapsedFolders: [],
showHint,
viewMode,
@ -288,11 +283,34 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
sidebarCollapsed: !sidebarOpen,
};
cachedDocumentListState = state;
void saveDocumentListState(state);
const saved = preferencesQuery.data?.preferences.documentListState;
if (
saved
&& saved.sortBy === state.sortBy
&& saved.sortDirection === state.sortDirection
&& (saved.showHint ?? true) === state.showHint
&& normalizeViewMode(saved.viewMode) === state.viewMode
&& (saved.iconSize ?? DEFAULT_STATE.iconSize) === state.iconSize
&& (saved.sidebarWidth ?? DEFAULT_STATE.sidebarWidth) === state.sidebarWidth
&& (saved.sidebarFilter ?? 'all') === state.sidebarFilter
&& (saved.sidebarCollapsed ?? false) === state.sidebarCollapsed
) {
return;
}
if (preferenceWriteTimer.current) clearTimeout(preferenceWriteTimer.current);
preferenceWriteTimer.current = setTimeout(() => {
persistPreferences({ documentListState: state });
preferenceWriteTimer.current = null;
}, 250);
return () => {
if (preferenceWriteTimer.current) {
clearTimeout(preferenceWriteTimer.current);
preferenceWriteTimer.current = null;
}
};
}, [
sortBy,
sortDirection,
folders,
showHint,
viewMode,
iconSize,
@ -300,6 +318,8 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
sidebarFilter,
sidebarOpen,
isInitialized,
persistPreferences,
preferencesQuery.data?.preferences.documentListState,
]);
// Mobile drawer should never auto-open from persisted desktop state.
@ -321,28 +341,26 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
() => rawDocuments.map((d) => documentIdentityKey(d)).sort().join('|'),
[rawDocuments],
);
const recentlyOpenedById = useLiveQuery<Record<string, number>, Record<string, number>>(
async () => {
try {
return await getDocumentRecentlyOpenedMap();
} catch (err) {
console.warn('Failed to load recently opened cache metadata:', err);
return {};
}
},
[rawDocumentIdsKey],
{},
);
const allDocuments: DocumentListDocument[] = useMemo(
() =>
rawDocuments.map((doc) => ({
...doc,
recentlyOpenedAt: recentlyOpenedById[documentIdentityKey(doc)] ?? 0,
recentlyOpenedAt: doc.recentlyOpenedAt ?? 0,
})),
[rawDocuments, recentlyOpenedById],
[rawDocuments],
);
useEffect(() => {
const serverFolders = folderState.query.data ?? [];
setFolders(serverFolders.map((folder) => ({
id: folder.id,
name: folder.name,
documents: rawDocuments
.filter((doc) => doc.folderId === folder.id)
.map((doc) => ({ ...doc, folderId: folder.id })),
})));
}, [folderState.query.data, rawDocumentIdsKey, rawDocuments]);
const allDocumentsById = useMemo(() => {
const map = new Map<string, DocumentListDocument>();
for (const doc of allDocuments) map.set(documentIdentityKey(doc), doc);
@ -473,10 +491,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
return { ...f, documents: [...f.documents, ...newDocs] };
}),
);
folderState.move.mutate({ documentIds: item.docs.map((doc) => doc.id), folderId });
setSidebarFilter(`folder:${folderId}`);
selection.clear();
},
[selection],
[folderState.move, selection],
);
const handleMergeIntoFolder = useCallback(
@ -491,50 +510,74 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
[],
);
const createFolderFromPending = useCallback(() => {
const createFolderFromPending = useCallback(async () => {
if (!pendingMerge) return;
const name =
newFolderName.trim() ||
generateDefaultFolderName(pendingMerge.sources[0], pendingMerge.target);
const folderId = `folder-${Date.now()}`;
setFolders((prev) => [
...prev,
{
id: folderId,
name,
documents: [
...pendingMerge.sources.map((d) => ({ ...d, folderId })),
{ ...pendingMerge.target, folderId },
],
},
]);
const documentIds = [...pendingMerge.sources, pendingMerge.target].map((doc) => doc.id);
const folderId = crypto.randomUUID();
setPendingMerge(null);
setNewFolderName('');
setShowHint(false);
setSidebarFilter(`folder:${folderId}`);
selection.clear();
}, [pendingMerge, newFolderName, selection]);
const { folder } = await folderState.create.mutateAsync({
id: folderId,
name,
documentIds,
});
setSidebarFilter(`folder:${folder.id}`);
}, [folderState.create, pendingMerge, newFolderName, selection]);
const handleDismissHint = useCallback(() => {
setShowHint(false);
persistPreferences({
documentListState: {
sortBy,
sortDirection,
folders: [],
collapsedFolders: [],
showHint: false,
viewMode,
iconSize,
sidebarWidth,
sidebarFilter,
sidebarCollapsed: !sidebarOpen,
},
});
}, [
iconSize,
persistPreferences,
sidebarFilter,
sidebarOpen,
sidebarWidth,
sortBy,
sortDirection,
viewMode,
]);
const createManualFolder = useCallback(() => {
const name = newFolderName.trim() || `New Folder`;
const folderId = `folder-${Date.now()}`;
setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]);
folderState.create.mutate({ name });
setNewFolderName('');
setManualFolderPrompt(false);
setSidebarFilter(`folder:${folderId}`);
}, [newFolderName]);
setSidebarFilter('all');
}, [folderState.create, newFolderName]);
const handleDeleteFolder = useCallback((folderId: string) => {
setFolders((prev) => prev.filter((f) => f.id !== folderId));
folderState.remove.mutate(folderId);
if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all');
}, [sidebarFilter]);
}, [folderState.remove, sidebarFilter]);
const handleClearFolders = useCallback(() => {
setFolders([]);
folderState.clear.mutate();
if (sidebarFilter.startsWith('folder:')) setSidebarFilter('all');
setClearFoldersPrompt(false);
selection.clear();
}, [selection, sidebarFilter]);
}, [folderState.clear, selection, sidebarFilter]);
// Status bar summary.
const summary = useMemo(() => {
@ -666,7 +709,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
Drag files onto each other to make folders. Drop into the sidebar to move.
</p>
<IconButton
onClick={() => setShowHint(false)}
onClick={handleDismissHint}
size="xs"
className="h-6 w-6"
aria-label="Dismiss hint"

View file

@ -132,7 +132,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
if (status.kind === 'ready') {
const primedUrl = await primeDocumentPreviewCache(
doc.id,
Number(doc.lastModified),
status.previewVersion || Number(doc.lastModified),
previewKey,
{ signal: controller.signal },
).catch(() => null);

View file

@ -1,186 +0,0 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { useDocuments } from '@/contexts/DocumentContext';
import type { BaseDocument } from '@/types/documents';
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
type DexieMigrationModalProps = {
isOpen: boolean;
localCount: number;
missingCount: number;
onComplete: () => void;
};
export function DexieMigrationModal({
isOpen,
localCount,
missingCount,
onComplete,
}: DexieMigrationModalProps) {
const { refreshDocuments } = useDocuments();
const [displayMissingCount, setDisplayMissingCount] = useState(missingCount);
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<string>('');
const closeDisabled = isUploading;
useEffect(() => {
setDisplayMissingCount(missingCount);
}, [missingCount, isOpen]);
const loadLocalDexieDocs = useCallback(async (): Promise<{
docs: BaseDocument[];
pdfById: Map<string, { id: string; name: string; size: number; lastModified: number; data: ArrayBuffer }>;
epubById: Map<string, { id: string; name: string; size: number; lastModified: number; data: ArrayBuffer }>;
htmlById: Map<string, { id: string; name: string; size: number; lastModified: number; data: string }>;
}> => {
const [pdfs, epubs, htmls] = await Promise.all([getAllPdfDocuments(), getAllEpubDocuments(), getAllHtmlDocuments()]);
const docs: BaseDocument[] = [
...pdfs.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'pdf' as const })),
...epubs.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'epub' as const })),
...htmls.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'html' as const })),
];
const pdfById = new Map(pdfs.map((d) => [d.id, d] as const));
const epubById = new Map(epubs.map((d) => [d.id, d] as const));
const htmlById = new Map(htmls.map((d) => [d.id, d] as const));
return { docs, pdfById, epubById, htmlById };
}, []);
const title = 'Upload your local documents?';
const handleSkip = useCallback(async () => {
await updateAppConfig({ documentsMigrationPrompted: true });
onComplete();
}, [onComplete]);
const handleUpload = useCallback(async () => {
setIsUploading(true);
setProgress(0);
setStatus('Preparing upload...');
try {
const { docs, pdfById, epubById, htmlById } = await loadLocalDexieDocs();
const serverDocs = await listDocuments().catch(() => null);
const serverIds = serverDocs ? new Set(serverDocs.map((d) => d.id)) : null;
const toUpload = serverIds ? docs.filter((d) => !serverIds.has(d.id)) : docs;
setDisplayMissingCount(toUpload.length);
const encoder = new TextEncoder();
for (let i = 0; i < toUpload.length; i++) {
const doc = toUpload[i];
setStatus(`Uploading ${i + 1}/${toUpload.length}: ${doc.name}`);
setProgress((i / Math.max(1, toUpload.length)) * 100);
// Pull raw data from Dexie for this doc
if (doc.type === 'pdf') {
const full = pdfById.get(doc.id) ?? null;
if (!full) continue;
const bytes = full.data.slice(0);
const file = new File([full.data], full.name, {
type: mimeTypeForDoc(doc),
lastModified: full.lastModified,
});
const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null;
if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
}
} else if (doc.type === 'epub') {
const full = epubById.get(doc.id) ?? null;
if (!full) continue;
const bytes = full.data.slice(0);
const file = new File([full.data], full.name, {
type: mimeTypeForDoc(doc),
lastModified: full.lastModified,
});
const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null;
if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
}
} else {
const full = htmlById.get(doc.id) ?? null;
if (!full) continue;
const bytes = encoder.encode(full.data).buffer;
const file = new File([full.data], full.name, {
type: mimeTypeForDoc(doc),
lastModified: full.lastModified,
});
const uploaded = await uploadDocuments([file]);
const stored = uploaded[0] ?? null;
if (stored) {
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
}
}
}
setProgress(100);
setStatus('Refreshing...');
await refreshDocuments();
await updateAppConfig({ documentsMigrationPrompted: true });
onComplete();
} catch (err) {
console.error('Dexie migration upload failed:', err);
setStatus('Upload failed. You can retry or skip.');
} finally {
setIsUploading(false);
}
}, [loadLocalDexieDocs, onComplete, refreshDocuments]);
return (
<ModalFrame
open={isOpen}
onClose={() => {
if (!closeDisabled) onComplete();
}}
panelTestId="migration-modal"
className="z-[80]"
>
<ModalTitle className="mb-4">{title}</ModalTitle>
<div className="space-y-2">
<p className="text-sm text-soft mb-2">
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
{displayMissingCount > 0 ? (
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
) : null}
{' '}This app now stores documents on the server and keeps a local cache for speed.
</p>
{isUploading && (
<div className="space-y-1">
<p className="text-xs text-soft">{status}</p>
<div className="h-2 w-full rounded bg-surface-sunken">
<div className="progress-fill h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
</div>
</div>
)}
{!isUploading && status ? <p className="text-xs text-danger">{status}</p> : null}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
data-testid="migration-skip-button"
variant="outline"
size="sm"
onClick={handleSkip}
disabled={isUploading}
>
Skip
</Button>
<Button
variant="primary"
size="sm"
onClick={handleUpload}
disabled={isUploading}
>
{isUploading ? 'Uploading…' : 'Upload'}
</Button>
</div>
</ModalFrame>
);
}

View file

@ -1,27 +1,18 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues } from '@/types/config';
import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { applyConfigUpdate } from '@/lib/client/config/updates';
import { useSharedProviders } from '@/hooks/useSharedProviders';
import toast from 'react-hot-toast';
import { useUserPreferences } from '@/hooks/useUserPreferences';
import type { SyncedPreferencesPatch } from '@/types/user-state';
export type { ViewType } from '@/types/config';
/** Configuration values for the application */
/** Interface defining the configuration context shape and functionality */
interface ConfigContextType {
apiKey: string;
baseUrl: string;
viewType: ViewType;
voiceSpeed: number;
audioPlayerSpeed: number;
@ -40,7 +31,6 @@ interface ConfigContextType {
ttsModel: string;
ttsInstructions: string;
savedVoices: SavedVoices;
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
updateConfigKey: <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => Promise<void>;
isLoading: boolean;
isDBReady: boolean;
@ -54,456 +44,82 @@ interface ConfigContextType {
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
/**
* Provider component for application configuration
* Manages global configuration state and persistence
* @param {Object} props - Component props
* @param {ReactNode} props.children - Child components to be wrapped by the provider
*/
export function ConfigProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
const [isDBReady, setIsDBReady] = useState(false);
const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
const showAllProviderModels = useFeatureFlag('showAllProviderModels');
const didRunStartupMigrations = useRef(false);
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
const { data: session, isPending: isSessionPending } = useAuthSession();
const sessionId = session?.user?.id ?? 'no-session';
const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders();
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
const sessionKey = sessionData?.user?.id ?? 'no-session';
// The instance/admin default provider. An empty user providerRef "inherits"
// this, resolved (admin slug -> concrete provider) where the value is used.
const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider;
const { query, mutation } = useUserPreferences(sessionId, !isSessionPending);
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
if (sessionKey === 'no-session') return;
const config = useMemo<AppConfigValues>(() => ({
...APP_CONFIG_DEFAULTS,
...(query.data?.preferences ?? {}),
}), [query.data?.preferences]);
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, sessionKey);
}, [sessionKey]);
// Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
useEffect(() => {
return () => {
cancelPendingPreferenceSync();
};
}, [sessionKey]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail;
const status = detail?.status;
if (status === 'opened') {
toast.dismiss('dexie-blocked');
return;
}
if (status === 'blocked' || status === 'stalled') {
const message =
'Database upgrade is waiting for another OpenReader tab. Close other OpenReader tabs and reload.';
toast.error(message, { id: 'dexie-blocked', duration: Infinity });
}
};
window.addEventListener('openreader:dexie', handler as EventListener);
return () => {
window.removeEventListener('openreader:dexie', handler as EventListener);
};
}, []);
useEffect(() => {
const initializeDB = async () => {
try {
setIsLoading(true);
await initDB();
setIsDBReady(true);
} catch (error) {
console.error('Error initializing Dexie:', error);
} finally {
setIsLoading(false);
}
};
initializeDB();
}, []);
useEffect(() => {
if (!isDBReady) return;
if (didRunStartupMigrations.current) return;
didRunStartupMigrations.current = true;
const run = async () => {
try {
await migrateLegacyDexieDocumentIdsToSha();
} catch (error) {
console.warn('Startup migrations failed:', error);
}
};
void run();
}, [isDBReady]);
const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
if (!isDBReady) return;
try {
const remote = await getUserPreferences({ signal });
if (!remote?.hasStoredPreferences) return;
if (!remote.preferences || Object.keys(remote.preferences).length === 0) return;
await updateAppConfig(remote.preferences as Partial<AppConfigRow>);
} catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to load synced preferences:', error);
}
}, [isDBReady]);
useEffect(() => {
if (!isDBReady || isSessionPending) return;
const controller = new AbortController();
refreshSyncedPreferencesFromServer(controller.signal).catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Synced preferences refresh failed:', error);
});
return () => controller.abort();
}, [isDBReady, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
useEffect(() => {
if (!isDBReady) return;
let activeController: AbortController | null = null;
const onFocus = () => {
if (activeController) activeController.abort();
activeController = new AbortController();
refreshSyncedPreferencesFromServer(activeController.signal).catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Focus synced preferences refresh failed:', error);
});
};
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
if (activeController) activeController.abort();
};
}, [isDBReady, refreshSyncedPreferencesFromServer]);
const appConfig = useLiveQuery(
async () => {
if (!isDBReady) return null;
const row = await db['app-config'].get('singleton');
return row ?? null;
},
[isDBReady],
null,
);
const config: AppConfigValues | null = useMemo(() => {
if (!appConfig) return null;
const { id, ...rest } = appConfig;
void id;
return { ...APP_CONFIG_DEFAULTS, ...rest };
}, [appConfig]);
useEffect(() => {
if (ttsProvidersTabDisabled && isDBReady && appConfig && !sharedProvidersLoading) {
const resetPatch: Partial<AppConfigRow> = {};
// When the provider tab is hidden, clear any user-set provider config back to
// "inherit the admin default" (empty) rather than baking in a concrete value.
if (appConfig.apiKey !== '') resetPatch.apiKey = '';
if (appConfig.baseUrl !== '') resetPatch.baseUrl = '';
if (appConfig.providerRef !== '') resetPatch.providerRef = '';
if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown';
if (appConfig.ttsModel !== '') resetPatch.ttsModel = '';
if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = '';
// Keep voice selection state intact so player/Audiobook voice pickers still
// work when the TTS providers tab is hidden. This reset is only for provider
// configuration fields.
if (Object.keys(resetPatch).length === 0) return;
updateAppConfig(resetPatch).catch((error) => {
console.warn('Failed to clear hidden TTS provider settings:', error);
});
queueSyncedPreferencePatch(resetPatch);
}
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
useEffect(() => {
if (restrictUserApiKeys && isDBReady && appConfig && !sharedProvidersLoading) {
const resetPatch: Partial<AppConfigRow> = {};
if (appConfig.apiKey !== '') resetPatch.apiKey = '';
if (appConfig.baseUrl !== '') resetPatch.baseUrl = '';
// Built-in providers aren't selectable in restricted mode. Clear any stale
// built-in selection (including the old 'custom-openai' default that used to
// be baked into every config) back to "inherit the admin default" so the
// user follows whatever shared provider the admin has configured.
if (isBuiltInTtsProviderId(appConfig.providerRef)) {
resetPatch.providerRef = '';
resetPatch.providerType = 'unknown';
resetPatch.ttsModel = '';
resetPatch.ttsInstructions = '';
}
if (Object.keys(resetPatch).length === 0) return;
updateAppConfig(resetPatch).catch((error) => {
console.warn('Failed to enforce restricted user API key mode:', error);
});
queueSyncedPreferencePatch(resetPatch);
}
}, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
useEffect(() => {
if (showAllProviderModels || !isDBReady || !appConfig || sharedProvidersLoading) return;
// Inheriting (empty providerRef): the effective model is resolved at read
// time, so there is nothing to persist/enforce here.
if (!appConfig.providerRef) return;
const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef,
providerType: appConfig.providerType,
const effectiveProvider = useMemo(() => resolveProviderDefaults({
providerRef: config.providerRef,
providerType: config.providerType,
sharedProviders,
fallbackProviderRef: adminDefaultProviderRef,
});
if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to enforce provider default model restriction:', error);
});
queueSyncedPreferencePatch(patch);
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, adminDefaultProviderRef, queueSyncedPreferencePatch, sharedProvidersLoading]);
}), [adminDefaultProviderRef, config.providerRef, config.providerType, sharedProviders]);
useEffect(() => {
if (!isDBReady || !appConfig || isSessionPending) return;
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
didAttemptInitialPreferenceSeedForSession.current = sessionKey;
const controller = new AbortController();
const run = async () => {
try {
const remote = await getUserPreferences({ signal: controller.signal });
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(), signal: controller.signal });
} catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to seed initial synced preferences from local Dexie:', error);
}
};
run().catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Initial synced preferences seed failed:', error);
});
return () => controller.abort();
}, [isDBReady, appConfig, isSessionPending, sessionKey]);
// Destructure for convenience and to match context shape
const {
apiKey,
baseUrl,
viewType,
voiceSpeed,
audioPlayerSpeed,
voice,
skipBlank,
epubTheme,
headerMargin,
footerMargin,
leftMargin,
rightMargin,
providerRef,
providerType: _persistedProviderType,
ttsModel,
ttsInstructions,
savedVoices,
segmentPreloadDepthPages,
segmentPreloadSentenceLookahead,
ttsSegmentMaxBlockLength,
pdfHighlightEnabled,
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled,
htmlHighlightEnabled,
htmlWordHighlightEnabled,
} = config || APP_CONFIG_DEFAULTS;
// Resolve the effective provider for consumers. An empty stored providerRef
// means "inherit the admin default", which we resolve here so the reader,
// voice pickers, and settings UI all see a concrete, usable provider without
// mutating the stored ("inherit") value.
const effectiveProvider = useMemo(
() => resolveProviderDefaults({
providerRef,
providerType: _persistedProviderType,
sharedProviders,
fallbackProviderRef: adminDefaultProviderRef,
}),
[providerRef, _persistedProviderType, sharedProviders, adminDefaultProviderRef],
);
const effectiveProviderRef = effectiveProvider.providerRef;
const providerType = effectiveProvider.providerType;
const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel;
const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions;
const effectiveTtsModel = config.ttsModel || effectiveProvider.defaultModel;
const effectiveTtsInstructions = config.ttsInstructions || effectiveProvider.defaultInstructions;
useEffect(() => {
if (!isDBReady || !appConfig || sharedProvidersLoading) return;
// Only persist a resolved providerType for an explicitly chosen provider.
// While inheriting (empty providerRef) the type stays unset in storage.
if (!appConfig.providerRef) return;
if (appConfig.providerType === providerType) return;
const patch: Partial<AppConfigRow> = { providerType };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to persist resolved providerType:', error);
});
queueSyncedPreferencePatch(patch);
}, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]);
void _persistedProviderType;
useEffect(() => {
if (!isDBReady || !appConfig || sharedProvidersLoading) return;
// Inheriting (empty providerRef): the effective model is resolved at read
// time; don't write a concrete model into the "inherit" state.
if (!appConfig.providerRef) return;
const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef,
providerType: appConfig.providerType,
sharedProviders,
fallbackProviderRef: adminDefaultProviderRef,
});
if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
// Heal stale fallback model values that were written while the provider UI
// was disabled and shared provider context was unavailable.
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) return;
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to normalize shared-provider default model:', error);
});
queueSyncedPreferencePatch(patch);
}, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]);
/**
* Updates multiple configuration values simultaneously
* Only saves API credentials if they are explicitly set
*/
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
try {
setIsLoading(true);
const updates: Partial<AppConfigRow> = {};
if (newConfig.apiKey !== undefined) {
updates.apiKey = newConfig.apiKey;
}
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;
} finally {
setIsLoading(false);
}
const updatePatch = async (patch: Partial<AppConfigValues>) => {
await mutation.mutateAsync(patch as SyncedPreferencesPatch);
};
/**
* Updates a single configuration value by key
* @param {K} key - The configuration key to update
* @param {AppConfigValues[K]} value - The new value for the configuration
*/
const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
try {
setIsLoading(true);
const { storagePatch, syncPatch } = applyConfigUpdate({
const { syncPatch } = applyConfigUpdate({
providerRef: effectiveProviderRef,
providerType,
providerType: effectiveProvider.providerType,
ttsModel: effectiveTtsModel,
savedVoices,
savedVoices: config.savedVoices,
}, key, value);
await updateAppConfig(storagePatch);
if (
key === 'voice' ||
key === 'providerRef' ||
key === 'providerType' ||
key === 'ttsModel' ||
key === 'savedVoices' ||
syncedPreferenceKeys.has(String(key))
) {
queueSyncedPreferencePatch(syncPatch);
}
} catch (error) {
console.error(`Error updating config key ${String(key)}:`, error);
throw error;
} finally {
setIsLoading(false);
}
await updatePatch(syncPatch);
};
const isLoading = isSessionPending || query.isPending || mutation.isPending || sharedProvidersLoading;
return (
<ConfigContext.Provider value={{
apiKey,
baseUrl,
viewType,
voiceSpeed,
audioPlayerSpeed,
voice,
skipBlank,
epubTheme,
segmentPreloadDepthPages,
segmentPreloadSentenceLookahead,
ttsSegmentMaxBlockLength,
headerMargin,
footerMargin,
leftMargin,
rightMargin,
viewType: config.viewType,
voiceSpeed: config.voiceSpeed,
audioPlayerSpeed: config.audioPlayerSpeed,
voice: config.voice,
skipBlank: config.skipBlank,
epubTheme: config.epubTheme,
segmentPreloadDepthPages: config.segmentPreloadDepthPages,
segmentPreloadSentenceLookahead: config.segmentPreloadSentenceLookahead,
ttsSegmentMaxBlockLength: config.ttsSegmentMaxBlockLength,
headerMargin: config.headerMargin,
footerMargin: config.footerMargin,
leftMargin: config.leftMargin,
rightMargin: config.rightMargin,
providerRef: effectiveProviderRef,
providerType,
providerType: effectiveProvider.providerType,
ttsModel: effectiveTtsModel,
ttsInstructions: effectiveTtsInstructions,
savedVoices,
updateConfig,
savedVoices: config.savedVoices,
updateConfigKey,
isLoading,
isDBReady,
pdfHighlightEnabled,
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled,
htmlHighlightEnabled,
htmlWordHighlightEnabled,
isDBReady: !isSessionPending && !query.isPending,
pdfHighlightEnabled: config.pdfHighlightEnabled,
pdfWordHighlightEnabled: config.pdfWordHighlightEnabled,
epubHighlightEnabled: config.epubHighlightEnabled,
epubWordHighlightEnabled: config.epubWordHighlightEnabled,
htmlHighlightEnabled: config.htmlHighlightEnabled,
htmlWordHighlightEnabled: config.htmlWordHighlightEnabled,
}}>
{children}
</ConfigContext.Provider>
);
}
/**
* Custom hook to consume the configuration context
* @returns {ConfigContextType} The configuration context value
* @throws {Error} When used outside of ConfigProvider
*/
export function useConfig() {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
if (!context) throw new Error('useConfig must be used within a ConfigProvider');
return context;
}

View file

@ -1,7 +1,7 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, ReactNode } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { BaseDocument } from '@/types/documents';
import {
deleteDocuments as deleteServerDocuments,
@ -102,10 +102,31 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
return { pdfDocs, epubDocs, htmlDocs };
}, [docs]);
const uploadMutation = useMutation({
mutationFn: (files: File[]) => uploadServerDocuments(files),
onSuccess: (stored) => {
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous) =>
mergeStoredDocuments(previous, stored),
);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: documentsQueryKey }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteServerDocuments({ ids: [id] }),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: documentsQueryKey });
const previous = queryClient.getQueryData<SupportedDocument[]>(documentsQueryKey);
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (rows = []) => rows.filter((doc) => doc.id !== id));
return { previous };
},
onError: (_error, _id, context) => queryClient.setQueryData(documentsQueryKey, context?.previous),
onSettled: () => queryClient.invalidateQueries({ queryKey: documentsQueryKey }),
});
const uploadDocuments = useCallback(async (files: File[]): Promise<BaseDocument[]> => {
if (files.length === 0) return [];
const stored = await uploadServerDocuments(files);
const stored = await uploadMutation.mutateAsync(files);
await Promise.allSettled(
stored.map(async (document, index) => {
const file = files[index];
@ -134,20 +155,13 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
}),
);
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous) =>
mergeStoredDocuments(previous, stored),
);
return stored;
}, [documentsQueryKey, queryClient]);
}, [uploadMutation]);
const deleteDocument = useCallback(async (id: string) => {
await deleteServerDocuments({ ids: [id] });
await deleteMutation.mutateAsync(id);
await evictCachedDocument(id);
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous = []) =>
previous.filter((document) => document.id !== id),
);
}, [documentsQueryKey, queryClient]);
}, [deleteMutation]);
return (
<DocumentContext.Provider value={{

View file

@ -2,16 +2,15 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal';
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
import { PrivacyModal } from '@/components/PrivacyModal';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie';
import { listDocuments } from '@/lib/client/api/documents';
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
import { scheduleChangelogCheck } from '@/lib/client/changelog-check';
import { createCoalescedAsyncRunner, resolveNextOnboardingStep } from '@/lib/client/onboarding-flow';
import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
import { useOnboardingState } from '@/hooks/useOnboardingState';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/client/query-keys';
type OnboardingFlowContextValue = {
changelogOpenSignal: number;
@ -19,18 +18,14 @@ type OnboardingFlowContextValue = {
const OnboardingFlowContext = createContext<OnboardingFlowContextValue | null>(null);
type MigrationPromptState = {
shouldPrompt: boolean;
localCount: number;
missingCount: number;
};
const EMPTY_CLAIM_COUNTS: ClaimableCounts = {
documents: 0,
audiobooks: 0,
preferences: 0,
progress: 0,
documentSettings: 0,
folders: 0,
onboarding: 0,
};
function toClaimableCounts(value: unknown): ClaimableCounts {
@ -41,6 +36,8 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
preferences: Number(rec.preferences ?? 0),
progress: Number(rec.progress ?? 0),
documentSettings: Number(rec.documentSettings ?? 0),
folders: Number(rec.folders ?? 0),
onboarding: Number(rec.onboarding ?? 0),
};
}
@ -53,71 +50,22 @@ async function fetchClaimableCounts(): Promise<ClaimableCounts> {
return toClaimableCounts(data);
}
async function getMigrationPromptState(privacyGateSatisfied: boolean): Promise<MigrationPromptState> {
const cfg = await getAppConfig();
if (!privacyGateSatisfied || cfg?.documentsMigrationPrompted) {
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
}
const [pdfs, epubs, htmls] = await Promise.all([
getAllPdfDocuments(),
getAllEpubDocuments(),
getAllHtmlDocuments(),
]);
const localDocs = [
...pdfs.map((d) => d.id),
...epubs.map((d) => d.id),
...htmls.map((d) => d.id),
];
const localCount = localDocs.length;
if (localCount === 0) {
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
}
const serverDocs = await listDocuments().catch(() => null);
if (!serverDocs) {
return { shouldPrompt: true, localCount, missingCount: localCount };
}
const serverIds = new Set(serverDocs.map((d) => d.id));
const missingCount = localDocs.filter((id) => !serverIds.has(id)).length;
return {
shouldPrompt: missingCount > 0,
localCount,
missingCount,
};
}
type LocalOnboardingSnapshot = {
privacyAccepted: boolean;
firstVisitSettingsOpened: boolean;
};
async function readLocalOnboardingSnapshot(): Promise<LocalOnboardingSnapshot> {
const appConfig = await getAppConfig();
const row = appConfig as Record<string, unknown> | null;
const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey;
const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey;
return {
privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false,
firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : false,
};
}
export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
const { data: session, isPending: isSessionPending } = useAuthSession();
const runtimeConfig = useRuntimeConfig();
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined;
const userId = user?.id ?? null;
const isAnonymous = Boolean(user?.isAnonymous);
const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | 'migration' | null>(null);
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>(EMPTY_CLAIM_COUNTS);
const [migrationCounts, setMigrationCounts] = useState<{ localCount: number; missingCount: number }>({
localCount: 0,
missingCount: 0,
const claimCountsQuery = useQuery({
queryKey: queryKeys.claimCounts(userId ?? 'no-session'),
queryFn: fetchClaimableCounts,
enabled: Boolean(userId && !isAnonymous),
});
const refetchClaimCounts = claimCountsQuery.refetch;
const { query: onboardingQuery } = useOnboardingState();
const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | null>(null);
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>(EMPTY_CLAIM_COUNTS);
const [changelogOpenSignal, setChangelogOpenSignal] = useState(0);
const pendingChangelogOpenRef = useRef(false);
@ -135,9 +83,17 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
);
const runOnceFlow = useCallback(async () => {
const local = await readLocalOnboardingSnapshot();
// Wait until the onboarding state has actually loaded before deciding whether
// to show the privacy modal. Otherwise the not-yet-loaded query (data === undefined)
// reads as "not accepted", the modal flashes on first paint, then closes once the
// real state arrives.
const onboardingData = onboardingQuery.data;
if (onboardingData === undefined) {
return;
}
const privacyRequired = true;
const privacyAccepted = !privacyRequired || local.privacyAccepted;
const privacyAccepted = !privacyRequired || Boolean(onboardingData.privacyAcceptedAtMs);
const isClaimEligible = Boolean(
userId
@ -149,26 +105,25 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
let claimHasData = false;
if (isClaimEligible) {
claimCounts = await fetchClaimableCounts();
claimCounts = (await refetchClaimCounts()).data ?? EMPTY_CLAIM_COUNTS;
const total = claimCounts.documents
+ claimCounts.audiobooks
+ claimCounts.preferences
+ claimCounts.progress
+ claimCounts.documentSettings;
+ claimCounts.documentSettings
+ claimCounts.folders
+ claimCounts.onboarding;
claimHasData = total > 0;
if (!claimHasData && userId) {
claimDismissedUsersRef.current.add(userId);
}
}
const migrationState = await getMigrationPromptState(privacyAccepted);
const nextStep = resolveNextOnboardingStep({
privacyRequired,
privacyAccepted,
claimEligible: isClaimEligible,
claimHasData,
migrationRequired: migrationState.shouldPrompt,
changelogPending: pendingChangelogOpenRef.current,
});
@ -183,26 +138,13 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
return;
}
if (nextStep === 'migration') {
setMigrationCounts({
localCount: migrationState.localCount,
missingCount: migrationState.missingCount,
});
setActiveBlockingModal('migration');
return;
}
setActiveBlockingModal(null);
if (!local.firstVisitSettingsOpened) {
await setFirstVisit(true);
}
if (nextStep === 'changelog') {
pendingChangelogOpenRef.current = false;
setChangelogOpenSignal((value) => value + 1);
}
}, [isAnonymous, userId]);
}, [isAnonymous, onboardingQuery.data, refetchClaimCounts, userId]);
runOnceFlowRef.current = runOnceFlow;
@ -214,11 +156,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
void runFlow();
}, [runFlow, userId]);
const handleMigrationComplete = useCallback(() => {
setActiveBlockingModal(null);
void runFlow();
}, [runFlow]);
const handlePrivacyAccepted = useCallback(() => {
setActiveBlockingModal(null);
void runFlow();
@ -226,7 +163,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
useEffect(() => {
void runFlow();
}, [isAnonymous, runFlow, userId]);
}, [isAnonymous, onboardingQuery.data, runFlow, userId]);
useEffect(() => {
const onPrivacyAccepted = () => {
@ -273,12 +210,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
onDismiss={handleClaimComplete}
onClaimed={handleClaimComplete}
/>
<DexieMigrationModal
isOpen={activeBlockingModal === 'migration'}
localCount={migrationCounts.localCount}
missingCount={migrationCounts.missingCount}
onComplete={handleMigrationComplete}
/>
</OnboardingFlowContext.Provider>
);
}

View file

@ -94,7 +94,7 @@ export function useFeatureFlag<K extends keyof RuntimeConfig>(key: K): RuntimeCo
/**
* Synchronous accessor for modules that are loaded before the React tree
* mounts (e.g. Dexie initialization, config defaults). Falls back to the
* mounts (for example config defaults). Falls back to the
* built-in defaults during SSR.
*/
export function readRuntimeConfigSync(): RuntimeConfig {

View file

@ -33,8 +33,7 @@ import { useConfig } from '@/contexts/ConfigContext';
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
import { useMediaSession } from '@/hooks/audio/useMediaSession';
import { useAudioContext } from '@/hooks/audio/useAudioContext';
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/client/dexie';
import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
import { useDocumentProgress } from '@/hooks/useDocumentProgress';
import { withRetry, ensureTtsSegments } from '@/lib/client/api/audiobooks';
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
import {
@ -82,6 +81,7 @@ import type {
TTSSegmentManifestItem,
} from '@/types/client';
import { isStableEpubLocator } from '@/types/client';
import { clearCachedAudioObjectUrls, getCachedAudioUrl } from '@/lib/client/cache/audio';
/**
* Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub',
@ -420,8 +420,6 @@ const TTSContext = createContext<TTSContextType | undefined>(undefined);
export function TTSProvider({ children }: { children: ReactNode }): ReactElement {
// Configuration context consumption
const {
apiKey: openApiKey,
baseUrl: openApiBaseUrl,
isLoading: configIsLoading,
voiceSpeed,
audioPlayerSpeed,
@ -444,8 +442,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Audio and voice management hooks
const audioContext = useAudioContext();
const { availableVoices, fetchVoices } = useVoiceManagement(
openApiKey,
openApiBaseUrl,
configProviderRef,
configProviderType,
configTTSModel,
@ -466,6 +462,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (Array.isArray(id)) return id[0];
return '';
}, [id]);
const { query: progressQuery, schedule: scheduleProgress } = useDocumentProgress(documentId || undefined);
const currentReaderType: ReaderType = useMemo(() => {
if (pathname.startsWith('/epub/')) return 'epub';
@ -729,6 +726,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
useEffect(() => () => {
clearWarmAudioCache();
clearCachedAudioObjectUrls();
}, [clearWarmAudioCache]);
const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => {
@ -1619,7 +1617,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setTTSModel(configTTSModel);
setTTSInstructions(configTTSInstructions);
}
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
}, [configIsLoading, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
const preloadGenerationSignatureRef = useRef<string>('');
useEffect(() => {
@ -1766,12 +1764,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
'x-openai-key': openApiKey || '',
'x-tts-provider': configProviderRef,
};
if (openApiBaseUrl) {
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
}
const retryOptions: TTSRetryOptions = {
maxRetries: 2,
@ -1914,8 +1908,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel,
ttsInstructions,
resolvedLanguage,
openApiKey,
openApiBaseUrl,
configProviderRef,
configProviderType,
providerModelPolicy.supportsInstructions,
@ -2044,7 +2036,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setCurrentSentenceAlignment(playbackSource.manifest.alignment);
setCurrentWordIndex(null);
}
const audioUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl;
const sourceUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl;
const audioUrl = await getCachedAudioUrl({
audioKey: playbackSource.manifest.segmentId,
version: playbackSource.manifest.durationMs,
primaryUrl: sourceUrl,
fallbackUrl: playbackSource.fallbackUrl,
}).catch(() => sourceUrl);
// Force unload any previous Howl instance to free up resources
if (activeHowl) {
@ -2610,12 +2608,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
activeAbortControllers.current.add(controller);
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
'x-openai-key': openApiKey || '',
'x-tts-provider': configProviderRef,
};
if (openApiBaseUrl) {
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
}
const retryOptions: TTSRetryOptions = {
maxRetries: 2,
initialDelay: 300,
@ -2903,12 +2897,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
activeAbortControllers.current.add(controller);
const reqHeaders: TTSRequestHeaders = {
'Content-Type': 'application/json',
'x-openai-key': openApiKey || '',
'x-tts-provider': configProviderRef,
};
if (openApiBaseUrl) {
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
}
const retryOptions: TTSRetryOptions = {
maxRetries: 2,
@ -3026,8 +3016,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
configProviderRef,
configProviderType,
ttsModel,
openApiKey,
openApiBaseUrl,
providerModelPolicy.supportsInstructions,
ttsInstructions,
resolvedLanguage,
@ -3397,13 +3385,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
skipBackward,
});
// Load last location on mount for EPUB/PDF/HTML.
// Prefer server-backed progress when available, then fall back to local Dexie.
// Load the server-backed last location for EPUB/PDF/HTML.
useEffect(() => {
if (!id) return;
let cancelled = false;
const docId = id as string;
if (!id || !progressQuery.data?.location) return;
const applyLocation = (lastLocation: string) => {
if (isEPUB && locationChangeHandlerRef.current) {
@ -3454,35 +3438,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}
};
const load = async () => {
try {
const local = await getLastDocumentLocation(docId);
if (!cancelled && local) {
applyLocation(local);
}
} catch (error) {
console.warn('Error loading local last location:', error);
}
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);
}
} catch (error) {
console.warn('Error loading remote progress:', error);
}
};
load();
return () => {
cancelled = true;
};
}, [id, isEPUB, currentReaderType]);
applyLocation(progressQuery.data.location);
}, [id, isEPUB, currentReaderType, progressQuery.data?.location]);
// Save current position periodically for non-EPUB readers.
useEffect(() => {
@ -3491,10 +3448,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
? `html:${encodeURIComponent(String(currDocPage || 1))}:${currentIndex}`
: `${currDocPageNumber}:${currentIndex}`;
const timeoutId = setTimeout(() => {
setLastDocumentLocation(id as string, location).catch(error => {
console.warn('Error saving non-EPUB location:', error);
});
scheduleDocumentProgressSync({
scheduleProgress({
documentId: id as string,
readerType: currentReaderType,
location,
@ -3503,7 +3457,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return () => clearTimeout(timeoutId);
}
}, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType]);
}, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType, scheduleProgress]);
/**
* Renders the TTS context provider with its children

View file

@ -7,16 +7,12 @@ import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'
/**
* Custom hook for managing TTS voices
* @param apiKey OpenAI API key
* @param baseUrl OpenAI API base URL
* @param providerRef TTS provider routing reference (built-in id or shared slug)
* @param providerType Resolved provider type for capability/default logic
* @param ttsModel TTS model name
* @returns Object containing available voices and fetch function
*/
export function useVoiceManagement(
apiKey: string | undefined,
baseUrl: string | undefined,
providerRef: string | undefined,
providerType: TtsProviderType | undefined,
ttsModel: string | undefined
@ -29,8 +25,6 @@ export function useVoiceManagement(
try {
console.log('Fetching voices...');
const data = await getVoices({
'x-openai-key': apiKey || '',
'x-openai-base-url': baseUrl || '',
'x-tts-provider': providerRef || 'openai',
'x-tts-model': ttsModel || 'tts-1',
'Content-Type': 'application/json',
@ -56,7 +50,7 @@ export function useVoiceManagement(
model: ttsModel || 'tts-1',
}).defaultVoices);
}
}, [apiKey, baseUrl, providerRef, providerType, ttsModel]);
}, [providerRef, providerType, ttsModel]);
return { availableVoices, fetchVoices };
}

View file

@ -35,8 +35,6 @@ export function filterNonEmptySpineTextEntries<T extends { text: string }>(entri
type UseEpubAudiobookParams = {
bookRef: RefObject<Book | null>;
tocRef: RefObject<NavItem[]>;
apiKey: string;
baseUrl: string;
providerRef: string;
};
@ -61,8 +59,6 @@ type UseEpubAudiobookResult = {
export function useEPUBAudiobook({
bookRef,
tocRef,
apiKey,
baseUrl,
providerRef,
}: UseEpubAudiobookParams): UseEpubAudiobookResult {
const loadSpineSection = useCallback(async (href: string) => {
@ -131,8 +127,6 @@ export function useEPUBAudiobook({
try {
return await runAudiobookGeneration({
adapter: audiobookAdapter,
apiKey,
baseUrl,
defaultProvider: providerRef,
onProgress,
signal,
@ -145,7 +139,7 @@ export function useEPUBAudiobook({
console.error('Error creating audiobook:', error);
throw error;
}
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
}, [audiobookAdapter, providerRef]);
const regenerateChapter = useCallback(async (
chapterIndex: number,
@ -161,8 +155,6 @@ export function useEPUBAudiobook({
bookId,
format,
signal,
apiKey,
baseUrl,
defaultProvider: providerRef,
settings,
});
@ -173,7 +165,7 @@ export function useEPUBAudiobook({
console.error('Error regenerating chapter:', error);
throw error;
}
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
}, [audiobookAdapter, providerRef]);
return {
createFullAudioBook,

View file

@ -1,53 +0,0 @@
'use client';
import { useCallback } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/client/dexie';
import type { EPUBDocument } from '@/types/documents';
import { sha256HexFromArrayBuffer } from '@/lib/client/sha256';
export function useEPUBDocuments() {
const documents = useLiveQuery(
() => db['epub-documents'].toArray(),
[],
undefined,
);
const isLoading = documents === undefined;
const addDocument = useCallback(async (file: File): Promise<string> => {
const arrayBuffer = await file.arrayBuffer();
const id = await sha256HexFromArrayBuffer(arrayBuffer);
console.log('Original file size:', file.size);
console.log('ArrayBuffer size:', arrayBuffer.byteLength);
const newDoc: EPUBDocument = {
id,
type: 'epub',
name: file.name,
size: file.size,
lastModified: file.lastModified,
data: arrayBuffer,
};
await db['epub-documents'].put(newDoc);
return id;
}, []);
const removeDocument = useCallback(async (id: string): Promise<void> => {
await db['epub-documents'].delete(id);
}, []);
const clearDocuments = useCallback(async (): Promise<void> => {
await db['epub-documents'].clear();
}, []);
return {
documents: documents ?? [],
isLoading,
addDocument,
removeDocument,
clearDocuments,
};
}

View file

@ -3,33 +3,14 @@
import { useCallback, type MutableRefObject, type RefObject } from 'react';
import type { Book, Rendition } from 'epubjs';
import { setLastDocumentLocation } from '@/lib/client/dexie';
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
import { useDocumentProgress } from '@/hooks/useDocumentProgress';
type EpubLocation = string | number;
export function isDirectionalEpubLocation(location: EpubLocation): location is 'next' | 'prev' {
return location === 'next' || location === 'prev';
}
export function shouldNavigateToDifferentCfi(
location: EpubLocation,
currentStartCfi: string | undefined,
): location is string {
return (
typeof location === 'string'
&& !isDirectionalEpubLocation(location)
&& !!currentStartCfi
&& location !== currentStartCfi
);
}
export function shouldPersistEpubLocation(
documentId: string | undefined,
previousLocation: EpubLocation,
): documentId is string {
return typeof documentId === 'string' && documentId.length > 0 && previousLocation !== 1;
}
import {
isDirectionalEpubLocation,
shouldNavigateToDifferentCfi,
shouldPersistEpubLocation,
type EpubLocation,
} from '@/lib/client/epub/location-controller';
type UseEpubLocationControllerParams = {
documentId?: string;
@ -54,6 +35,7 @@ export function useEPUBLocationController({
renditionRef,
locationRef,
}: UseEpubLocationControllerParams): (location: EpubLocation) => void {
const { schedule: scheduleProgress } = useDocumentProgress(documentId);
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
const book = bookRef.current;
const rendition = renditionRef.current;
@ -133,10 +115,9 @@ export function useEPUBLocationController({
return;
}
// Save the location to IndexedDB if not initial
// Save the server-backed location after the first real rendition update.
if (shouldPersistEpubLocation(documentId, locationRef.current)) {
setLastDocumentLocation(documentId, location.toString());
scheduleDocumentProgressSync({
scheduleProgress({
documentId,
readerType: 'epub',
location: location.toString(),
@ -160,8 +141,11 @@ export function useEPUBLocationController({
safeRenditionNavigate,
setIsEpub,
shouldPauseRef,
scheduleProgress,
skipToLocation,
]);
return handleLocationChanged;
}
export { isDirectionalEpubLocation, shouldNavigateToDifferentCfi, shouldPersistEpubLocation };

View file

@ -1,52 +0,0 @@
'use client';
import { useCallback } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/client/dexie';
import type { HTMLDocument } from '@/types/documents';
import { sha256HexFromString } from '@/lib/client/sha256';
export function useHTMLDocuments() {
const documents = useLiveQuery(
() => db['html-documents'].toArray(),
[],
undefined,
);
const isLoading = documents === undefined;
const addDocument = useCallback(async (file: File): Promise<string> => {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const content = new TextDecoder().decode(bytes);
const id = await sha256HexFromString(content);
const newDoc: HTMLDocument = {
id,
type: 'html',
name: file.name,
size: file.size,
lastModified: file.lastModified,
data: content,
};
await db['html-documents'].put(newDoc);
return id;
}, []);
const removeDocument = useCallback(async (id: string): Promise<void> => {
await db['html-documents'].delete(id);
}, []);
const clearDocuments = useCallback(async (): Promise<void> => {
await db['html-documents'].clear();
}, []);
return {
documents: documents ?? [],
isLoading,
addDocument,
removeDocument,
clearDocuments,
};
}

View file

@ -1,50 +0,0 @@
'use client';
import { useCallback } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/client/dexie';
import type { PDFDocument } from '@/types/documents';
import { sha256HexFromArrayBuffer } from '@/lib/client/sha256';
export function usePDFDocuments() {
const documents = useLiveQuery(
() => db['pdf-documents'].toArray(),
[],
undefined,
);
const isLoading = documents === undefined;
const addDocument = useCallback(async (file: File): Promise<string> => {
const arrayBuffer = await file.arrayBuffer();
const id = await sha256HexFromArrayBuffer(arrayBuffer);
const newDoc: PDFDocument = {
id,
type: 'pdf',
name: file.name,
size: file.size,
lastModified: file.lastModified,
data: arrayBuffer,
};
await db['pdf-documents'].put(newDoc);
return id;
}, []);
const removeDocument = useCallback(async (id: string): Promise<void> => {
await db['pdf-documents'].delete(id);
}, []);
const clearDocuments = useCallback(async (): Promise<void> => {
await db['pdf-documents'].clear();
}, []);
return {
documents: documents ?? [],
isLoading,
addDocument,
removeDocument,
clearDocuments,
};
}

View file

@ -1,52 +1,31 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import { getDocumentSettings, putDocumentSettings } from '@/lib/client/api/documents';
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
import { useDocumentSettings } from '@/hooks/useDocumentSettings';
export function useDocumentLanguage(documentId: string | undefined): {
language: string;
updateLanguage: (language: string) => Promise<void>;
} {
const [settings, setSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
useEffect(() => {
setSettings(DEFAULT_DOCUMENT_SETTINGS);
if (!documentId) return;
const controller = new AbortController();
void getDocumentSettings(documentId, { signal: controller.signal })
.then((response) => {
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
})
.catch((error) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
console.warn('Failed to load document language, using automatic detection:', error);
});
return () => controller.abort();
}, [documentId]);
const { query, mutation } = useDocumentSettings(documentId);
const settings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, query.data?.settings);
const updateLanguage = useCallback(async (language: string): Promise<void> => {
if (!documentId) return;
let next = DEFAULT_DOCUMENT_SETTINGS;
setSettings((prev) => {
next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
...prev,
const next: DocumentSettings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
...settings,
schemaVersion: 1,
language,
});
return next;
});
try {
const response = await putDocumentSettings(documentId, next);
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
await mutation.mutateAsync(next);
} catch (error) {
console.warn('Failed to persist document language:', error);
}
}, [documentId]);
}, [documentId, mutation, settings]);
return {
language: settings.language ?? 'auto',

View file

@ -0,0 +1,54 @@
'use client';
import { useCallback, useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getDocumentProgress, putDocumentProgress } from '@/lib/client/api/user-state';
import { queryKeys } from '@/lib/client/query-keys';
import { useAuthSession } from '@/hooks/useAuthSession';
import type { DocumentProgressRecord, ReaderType } from '@/types/user-state';
export function useDocumentProgress(documentId: string | undefined) {
const { data: session, isPending } = useAuthSession();
const sessionId = session?.user?.id ?? 'no-session';
const key = queryKeys.progress(sessionId, documentId ?? '');
const queryClient = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const query = useQuery({
queryKey: key,
queryFn: ({ signal }) => getDocumentProgress(documentId!, { signal }),
enabled: !isPending && !!documentId,
});
const mutation = useMutation({
mutationFn: putDocumentProgress,
onMutate: async (payload) => {
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<DocumentProgressRecord | null>(key);
queryClient.setQueryData(key, {
documentId: payload.documentId,
readerType: payload.readerType,
location: payload.location,
progress: payload.progress ?? null,
clientUpdatedAtMs: Date.now(),
updatedAtMs: Date.now(),
});
return { previous };
},
onError: (_error, _payload, context) => queryClient.setQueryData(key, context?.previous),
onSuccess: (data) => queryClient.setQueryData(key, data),
onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
});
const mutateProgress = mutation.mutate;
const schedule = useCallback((payload: {
documentId: string;
readerType: ReaderType;
location: string;
progress?: number | null;
}, debounceMs = 1000) => {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => mutateProgress(payload), debounceMs);
}, [mutateProgress]);
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
}, []);
return { query, mutation, schedule };
}

View file

@ -0,0 +1,32 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getDocumentSettings, putDocumentSettings } from '@/lib/client/api/documents';
import { queryKeys } from '@/lib/client/query-keys';
import { useAuthSession } from '@/hooks/useAuthSession';
import type { DocumentSettings } from '@/types/document-settings';
export function useDocumentSettings(documentId: string | undefined) {
const { data: session, isPending } = useAuthSession();
const sessionId = session?.user?.id ?? 'no-session';
const key = queryKeys.documentSettings(sessionId, documentId ?? '');
const queryClient = useQueryClient();
const query = useQuery({
queryKey: key,
queryFn: ({ signal }) => getDocumentSettings(documentId!, { signal }),
enabled: !isPending && !!documentId,
});
const mutation = useMutation({
mutationFn: (settings: DocumentSettings) => putDocumentSettings(documentId!, settings),
onMutate: async (settings) => {
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData(key);
queryClient.setQueryData(key, { settings, clientUpdatedAtMs: Date.now(), hasStoredSettings: true });
return { previous };
},
onError: (_error, _settings, context) => queryClient.setQueryData(key, context?.previous),
onSuccess: (data) => queryClient.setQueryData(key, data),
onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
});
return { query, mutation };
}

103
src/hooks/useFolders.ts Normal file
View file

@ -0,0 +1,103 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/client/query-keys';
import { useAuthSession } from '@/hooks/useAuthSession';
import type { BaseDocument } from '@/types/documents';
export type ServerFolder = { id: string; name: string; position: number; createdAt?: number; updatedAt?: number };
async function jsonRequest<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const data = await res.json().catch(() => null) as { error?: string } | null;
throw new Error(data?.error || 'Folder request failed');
}
return res.json() as Promise<T>;
}
export function useFolders() {
const { data: session, isPending } = useAuthSession();
const sessionId = session?.user?.id ?? 'no-session';
const foldersKey = queryKeys.folders(sessionId);
const documentsKey = queryKeys.documents(sessionId);
const queryClient = useQueryClient();
const query = useQuery({
queryKey: foldersKey,
queryFn: async ({ signal }) => (await jsonRequest<{ folders: ServerFolder[] }>('/api/folders', { signal })).folders,
enabled: !isPending,
});
const create = useMutation({
mutationFn: (input: { id?: string; name: string; documentIds?: string[] }) => jsonRequest<{ folder: ServerFolder }>('/api/folders', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
}),
onSuccess: ({ folder }, input) => {
queryClient.setQueryData<ServerFolder[]>(foldersKey, (rows = []) => [...rows, folder]);
if (input.documentIds?.length) queryClient.setQueryData<BaseDocument[]>(documentsKey, (rows = []) =>
rows.map((doc) => input.documentIds!.includes(doc.id) ? { ...doc, folderId: folder.id } : doc));
},
onSettled: () => Promise.all([
queryClient.invalidateQueries({ queryKey: foldersKey }),
queryClient.invalidateQueries({ queryKey: documentsKey }),
]),
});
const move = useMutation({
mutationFn: (input: { documentIds: string[]; folderId: string | null }) => jsonRequest('/api/documents/folders', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
}),
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: documentsKey });
const previous = queryClient.getQueryData<BaseDocument[]>(documentsKey);
queryClient.setQueryData<BaseDocument[]>(documentsKey, (rows = []) =>
rows.map((doc) => input.documentIds.includes(doc.id) ? { ...doc, folderId: input.folderId ?? undefined } : doc));
return { previous };
},
onError: (_error, _input, context) => queryClient.setQueryData(documentsKey, context?.previous),
onSettled: () => queryClient.invalidateQueries({ queryKey: documentsKey }),
});
const remove = useMutation({
mutationFn: (id: string) => jsonRequest(`/api/folders/${encodeURIComponent(id)}`, { method: 'DELETE' }),
onMutate: async (id) => {
await Promise.all([
queryClient.cancelQueries({ queryKey: foldersKey }),
queryClient.cancelQueries({ queryKey: documentsKey }),
]);
const previousFolders = queryClient.getQueryData<ServerFolder[]>(foldersKey);
const previousDocuments = queryClient.getQueryData<BaseDocument[]>(documentsKey);
queryClient.setQueryData<ServerFolder[]>(foldersKey, (rows = []) => rows.filter((folder) => folder.id !== id));
queryClient.setQueryData<BaseDocument[]>(documentsKey, (rows = []) => rows.map((doc) => doc.folderId === id ? { ...doc, folderId: undefined } : doc));
return { previousFolders, previousDocuments };
},
onError: (_error, _id, context) => {
queryClient.setQueryData(foldersKey, context?.previousFolders);
queryClient.setQueryData(documentsKey, context?.previousDocuments);
},
onSettled: () => Promise.all([
queryClient.invalidateQueries({ queryKey: foldersKey }),
queryClient.invalidateQueries({ queryKey: documentsKey }),
]),
});
const clear = useMutation({
mutationFn: () => jsonRequest('/api/folders', { method: 'DELETE' }),
onMutate: async () => {
await Promise.all([
queryClient.cancelQueries({ queryKey: foldersKey }),
queryClient.cancelQueries({ queryKey: documentsKey }),
]);
const previousFolders = queryClient.getQueryData<ServerFolder[]>(foldersKey);
const previousDocuments = queryClient.getQueryData<BaseDocument[]>(documentsKey);
queryClient.setQueryData(foldersKey, []);
queryClient.setQueryData<BaseDocument[]>(documentsKey, (rows = []) => rows.map((doc) => ({ ...doc, folderId: undefined })));
return { previousFolders, previousDocuments };
},
onError: (_error, _input, context) => {
queryClient.setQueryData(foldersKey, context?.previousFolders);
queryClient.setQueryData(documentsKey, context?.previousDocuments);
},
onSettled: () => Promise.all([
queryClient.invalidateQueries({ queryKey: foldersKey }),
queryClient.invalidateQueries({ queryKey: documentsKey }),
]),
});
return { query, create, move, remove, clear };
}

View file

@ -0,0 +1,47 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/client/query-keys';
import { useAuthSession } from '@/hooks/useAuthSession';
export type OnboardingState = { privacyAcceptedAtMs: number | null; lastSeenAppVersion: string | null };
async function fetchOnboarding(signal?: AbortSignal): Promise<OnboardingState> {
const res = await fetch('/api/user/state/onboarding', { signal });
if (!res.ok) throw new Error('Failed to load onboarding state');
return ((await res.json()) as { onboarding: OnboardingState }).onboarding;
}
export function useOnboardingState() {
const { data: session, isPending } = useAuthSession();
const sessionId = session?.user?.id ?? 'no-session';
const key = queryKeys.onboarding(sessionId);
const queryClient = useQueryClient();
const query = useQuery({ queryKey: key, queryFn: ({ signal }) => fetchOnboarding(signal), enabled: !isPending });
const mutation = useMutation({
mutationFn: async (patch: { privacyAccepted?: boolean; lastSeenAppVersion?: string }) => {
const res = await fetch('/api/user/state/onboarding', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error('Failed to update onboarding state');
return ((await res.json()) as { onboarding: OnboardingState }).onboarding;
},
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<OnboardingState>(key);
queryClient.setQueryData<OnboardingState>(key, (current) => ({
privacyAcceptedAtMs: patch.privacyAccepted === undefined
? current?.privacyAcceptedAtMs ?? null
: patch.privacyAccepted ? Date.now() : null,
lastSeenAppVersion: patch.lastSeenAppVersion ?? current?.lastSeenAppVersion ?? null,
}));
return { previous };
},
onError: (_error, _patch, context) => queryClient.setQueryData(key, context?.previous),
onSuccess: (data) => queryClient.setQueryData(key, data),
onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
});
return { query, mutation };
}

View file

@ -0,0 +1,29 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
import { queryKeys } from '@/lib/client/query-keys';
import type { SyncedPreferencesPatch } from '@/types/user-state';
export function useUserPreferences(sessionId: string, enabled: boolean) {
const queryClient = useQueryClient();
const key = queryKeys.preferences(sessionId);
const query = useQuery({ queryKey: key, queryFn: ({ signal }) => getUserPreferences({ signal }), enabled });
const mutation = useMutation({
mutationFn: (patch: SyncedPreferencesPatch) => putUserPreferences(patch),
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<Awaited<ReturnType<typeof getUserPreferences>>>(key);
queryClient.setQueryData(key, {
preferences: { ...(previous?.preferences ?? {}), ...patch },
clientUpdatedAtMs: Date.now(),
hasStoredPreferences: true,
});
return { previous };
},
onError: (_error, _patch, context) => queryClient.setQueryData(key, context?.previous),
onSuccess: (data) => queryClient.setQueryData(key, data),
onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
});
return { query, mutation };
}

View file

@ -85,7 +85,11 @@ export async function listDocuments(options?: { ids?: string[]; signal?: AbortSi
export async function getDocumentMetadata(id: string, options?: { signal?: AbortSignal }): Promise<BaseDocument | null> {
const docs = await listDocuments({ ids: [id], signal: options?.signal });
return docs[0] ?? null;
const document = docs[0] ?? null;
if (document) {
void fetch(`/api/documents/${encodeURIComponent(id)}/opened`, { method: 'PUT' }).catch(() => {});
}
return document;
}
export class ParsedPdfNotReadyError extends Error {
@ -439,9 +443,13 @@ export async function deleteDocuments(options?: { ids?: string[]; signal?: Abort
}
export async function downloadDocumentContent(id: string, options?: { signal?: AbortSignal }): Promise<ArrayBuffer> {
return (await fetchDocumentContentResponse(id, options)).arrayBuffer();
}
export async function fetchDocumentContentResponse(id: string, options?: { signal?: AbortSignal }): Promise<Response> {
const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(id)}`;
const fetchFallback = async (): Promise<ArrayBuffer> => {
const fetchFallback = async (): Promise<Response> => {
const res = await fetch(fallbackUrl, { signal: options?.signal });
if (!res.ok) {
const contentType = res.headers.get('content-type') || '';
@ -451,7 +459,7 @@ export async function downloadDocumentContent(id: string, options?: { signal?: A
}
throw new Error(`Failed to download document (status ${res.status})`);
}
return res.arrayBuffer();
return res;
};
try {
@ -467,7 +475,7 @@ export async function downloadDocumentContent(id: string, options?: { signal?: A
}
throw new Error(`Failed to download document (status ${directRes.status})`);
}
return directRes.arrayBuffer();
return directRes;
} catch (error) {
if (options?.signal?.aborted) throw error;
return fetchFallback();
@ -508,6 +516,7 @@ export type DocumentPreviewReady = {
fallbackUrl: string;
presignUrl: string;
directUrl?: string;
previewVersion: string;
};
export type DocumentPreviewStatus = DocumentPreviewPending | DocumentPreviewReady;
@ -556,12 +565,14 @@ export async function getDocumentPreviewStatus(
fallbackUrl?: string;
presignUrl?: string;
directUrl?: string;
previewVersion?: string;
} | null;
return {
kind: 'ready',
fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id),
presignUrl: data?.presignUrl || documentPreviewPresignUrl(id),
directUrl: data?.directUrl,
previewVersion: data?.previewVersion || '',
};
}
@ -606,4 +617,3 @@ export async function importUrl(
return (await res.json()) as { title: string; content: string };
}

View file

@ -78,102 +78,6 @@ export async function postChangelogVersionCheck(
return (await res.json()) as ChangelogVersionCheckResponse;
}
type PendingPreferenceSync = {
patch: SyncedPreferencesPatch;
timer: ReturnType<typeof setTimeout> | null;
sessionId: string | null;
};
const pendingPreferenceSync: PendingPreferenceSync = {
patch: {},
timer: null,
sessionId: null,
};
let activeSyncController: AbortController | null = null;
/**
* Cancel any pending debounced preference sync and abort in-flight requests.
* Call this on session change / sign-out to prevent cross-account writes.
*/
export function cancelPendingPreferenceSync(): void {
if (pendingPreferenceSync.timer) {
clearTimeout(pendingPreferenceSync.timer);
pendingPreferenceSync.timer = null;
}
pendingPreferenceSync.patch = {};
pendingPreferenceSync.sessionId = null;
if (activeSyncController) {
activeSyncController.abort();
activeSyncController = null;
}
}
export function scheduleUserPreferencesSync(
patch: SyncedPreferencesPatch,
sessionId: string,
debounceMs: number = 600,
): void {
Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch));
pendingPreferenceSync.sessionId = sessionId;
if (pendingPreferenceSync.timer) {
clearTimeout(pendingPreferenceSync.timer);
}
const capturedSessionId = sessionId;
pendingPreferenceSync.timer = setTimeout(async () => {
// If the session changed between scheduling and firing, discard.
if (pendingPreferenceSync.sessionId !== capturedSessionId) return;
const payload = { ...pendingPreferenceSync.patch };
pendingPreferenceSync.patch = {};
pendingPreferenceSync.timer = null;
if (Object.keys(payload).length === 0) return;
// Abort any previous in-flight sync and create a fresh controller.
if (activeSyncController) activeSyncController.abort();
activeSyncController = new AbortController();
try {
await putUserPreferences(payload, { signal: activeSyncController.signal });
} catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to sync user preferences:', error);
} finally {
activeSyncController = null;
}
}, debounceMs);
}
/**
* Immediately send any pending debounced preference patch and await the result.
* Use this when the user explicitly saves: we want the server write to complete
* (and surface failures) instead of silently relying on the debounce timer, which
* can be lost if the page is reloaded inside the debounce window.
*/
export async function flushUserPreferencesSync(): Promise<void> {
if (pendingPreferenceSync.timer) {
clearTimeout(pendingPreferenceSync.timer);
pendingPreferenceSync.timer = null;
}
const payload = { ...pendingPreferenceSync.patch };
pendingPreferenceSync.patch = {};
if (Object.keys(payload).length === 0) return;
if (activeSyncController) activeSyncController.abort();
activeSyncController = new AbortController();
try {
await putUserPreferences(payload, { signal: activeSyncController.signal });
} finally {
activeSyncController = null;
}
}
export async function getDocumentProgress(
documentId: string,
options?: { signal?: AbortSignal },
@ -216,56 +120,3 @@ export async function putDocumentProgress(payload: {
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

@ -29,8 +29,6 @@ export interface AudiobookSourceAdapter {
interface RunAudiobookGenerationOptions {
adapter: AudiobookSourceAdapter;
apiKey: string;
baseUrl: string;
defaultProvider: string;
onProgress: (progress: number) => void;
signal?: AbortSignal;
@ -47,8 +45,6 @@ interface RegenerateAudiobookChapterOptions {
bookId: string;
format: TTSAudiobookFormat;
signal: AbortSignal;
apiKey: string;
baseUrl: string;
defaultProvider: string;
settings?: AudiobookGenerationSettings;
retryOptions?: TTSRetryOptions;
@ -71,14 +67,10 @@ function resolveAudiobookRequestSettings(
}
function buildAudiobookRequestHeaders(
apiKey: string,
baseUrl: string,
effectiveProvider: string,
): TTSRequestHeaders {
return {
'Content-Type': 'application/json',
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
'x-tts-provider': effectiveProvider,
};
}
@ -95,8 +87,6 @@ function createAudiobookAbortError(): Error {
export async function runAudiobookGeneration({
adapter,
apiKey,
baseUrl,
defaultProvider,
onProgress,
signal,
@ -120,7 +110,7 @@ export async function runAudiobookGeneration({
}
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef);
let processedLength = 0;
let bookId = providedBookId;
@ -221,8 +211,6 @@ export async function regenerateAudiobookChapter({
bookId,
format,
signal,
apiKey,
baseUrl,
defaultProvider,
settings,
retryOptions = {
@ -238,7 +226,7 @@ export async function regenerateAudiobookChapter({
}
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef);
return withRetry(
async () => {

28
src/lib/client/cache/audio.ts vendored Normal file
View file

@ -0,0 +1,28 @@
import { audioBlobCacheKey, getCachedBlob } from '@/lib/client/cache/blob-cache';
const objectUrls = new Map<string, string>();
export async function getCachedAudioUrl(input: {
audioKey: string;
version: string | number;
primaryUrl: string | null;
fallbackUrl: string | null;
}): Promise<string> {
const key = audioBlobCacheKey(input.audioKey, input.version);
const existing = objectUrls.get(key);
if (existing) return existing;
const response = await getCachedBlob(key, async () => {
const primary = input.primaryUrl ? await fetch(input.primaryUrl).catch(() => null) : null;
if (primary?.ok) return primary;
if (!input.fallbackUrl) return primary ?? new Response(null, { status: 404 });
return fetch(input.fallbackUrl);
});
const url = URL.createObjectURL(await response.blob());
objectUrls.set(key, url);
return url;
}
export function clearCachedAudioObjectUrls(): void {
for (const url of objectUrls.values()) URL.revokeObjectURL(url);
objectUrls.clear();
}

60
src/lib/client/cache/blob-cache.ts vendored Normal file
View file

@ -0,0 +1,60 @@
const BLOB_CACHE_NAME = 'openreader-blobs-v1';
function canUseCacheStorage(): boolean {
return typeof window !== 'undefined' && typeof caches !== 'undefined';
}
export async function getCachedBlob(
stableKey: string,
fetchSource: () => Promise<Response>,
): Promise<Response> {
let cache: Cache | null = null;
if (canUseCacheStorage()) {
try {
cache = await caches.open(BLOB_CACHE_NAME);
const cached = await cache.match(stableKey);
if (cached) return cached;
} catch {
cache = null;
}
}
const response = await fetchSource();
if (!response.ok) throw new Error(`Blob fetch failed: ${response.status}`);
if (cache && response.status === 200 && response.type !== 'opaque' && !response.headers.has('Content-Range')) {
await cache.put(stableKey, response.clone()).catch(() => {});
}
return response;
}
export async function putCachedBlob(stableKey: string, response: Response): Promise<void> {
if (!canUseCacheStorage() || !response.ok || response.status !== 200 || response.type === 'opaque') return;
const cache = await caches.open(BLOB_CACHE_NAME).catch(() => null);
await cache?.put(stableKey, response.clone()).catch(() => {});
}
export async function evictCachedBlobPrefix(prefix: string): Promise<void> {
if (!canUseCacheStorage()) return;
const cache = await caches.open(BLOB_CACHE_NAME).catch(() => null);
if (!cache) return;
const keys = await cache.keys().catch(() => []);
await Promise.allSettled(keys.filter((request) => new URL(request.url).pathname.startsWith(prefix)).map((request) => cache.delete(request)));
}
export async function clearBlobCache(): Promise<void> {
if (!canUseCacheStorage()) return;
await caches.delete(BLOB_CACHE_NAME).catch(() => {});
}
export function documentBlobCacheKey(documentId: string, contentVersion: string): string {
return `/openreader-cache/documents/${encodeURIComponent(documentId)}/${encodeURIComponent(contentVersion)}`;
}
export function previewBlobCacheKey(documentId: string, previewVersion: string | number): string {
return `/openreader-cache/previews/${encodeURIComponent(documentId)}/${encodeURIComponent(String(previewVersion))}`;
}
export function audioBlobCacheKey(audioKey: string, version: string | number): string {
return `/openreader-cache/audio/${encodeURIComponent(audioKey)}/${encodeURIComponent(String(version))}`;
}

View file

@ -1,152 +1,50 @@
import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '@/types/documents';
import { downloadDocumentContent } from '@/lib/client/api/documents';
import { fetchDocumentContentResponse } from '@/lib/client/api/documents';
import {
clearBlobCache,
documentBlobCacheKey,
evictCachedBlobPrefix,
getCachedBlob,
putCachedBlob,
} from '@/lib/client/cache/blob-cache';
export type DocumentCacheBackend = {
get: (meta: BaseDocument) => Promise<PDFDocument | EPUBDocument | HTMLDocument | null>;
putPdf: (meta: BaseDocument, data: ArrayBuffer) => Promise<void>;
putEpub: (meta: BaseDocument, data: ArrayBuffer) => Promise<void>;
putHtml: (meta: BaseDocument, data: string) => Promise<void>;
download: (id: string, options?: { signal?: AbortSignal }) => Promise<ArrayBuffer>;
decodeText: (buffer: ArrayBuffer) => string;
};
function stableKey(meta: BaseDocument): string {
return documentBlobCacheKey(meta.id, meta.contentVersion || meta.id);
}
export async function ensureCachedDocumentCore(
async function readDocument(meta: BaseDocument, response: Response): Promise<PDFDocument | EPUBDocument | HTMLDocument> {
if (meta.type === 'html') {
return { ...meta, type: 'html', data: await response.text() };
}
const data = await response.arrayBuffer();
if (meta.type === 'epub') return { ...meta, type: 'epub', data };
return { ...meta, type: 'pdf', data };
}
export async function ensureCachedDocument(
meta: BaseDocument,
backend: DocumentCacheBackend,
options?: { signal?: AbortSignal },
): Promise<PDFDocument | EPUBDocument | HTMLDocument> {
const cached = await backend.get(meta);
if (cached) return cached;
const buffer = await backend.download(meta.id, { signal: options?.signal });
if (meta.type === 'pdf') {
await backend.putPdf(meta, buffer);
const after = await backend.get(meta);
if (!after || after.type !== 'pdf') throw new Error('Failed to cache PDF');
return after;
if (meta.type !== 'pdf' && meta.type !== 'epub' && meta.type !== 'html') {
throw new Error(`Unsupported cached document type: ${meta.type}`);
}
if (meta.type === 'epub') {
await backend.putEpub(meta, buffer);
const after = await backend.get(meta);
if (!after || after.type !== 'epub') throw new Error('Failed to cache EPUB');
return after;
}
const decoded = backend.decodeText(buffer);
await backend.putHtml(meta, decoded);
const after = await backend.get(meta);
if (!after || after.type !== 'html') throw new Error('Failed to cache HTML');
return after;
}
export async function getCachedPdf(id: string): Promise<PDFDocument | null> {
const { getPdfDocument } = await import('@/lib/client/dexie');
return (await getPdfDocument(id)) ?? null;
}
export async function putCachedPdf(meta: BaseDocument, data: ArrayBuffer): Promise<void> {
const { addPdfDocument } = await import('@/lib/client/dexie');
await addPdfDocument({
id: meta.id,
type: 'pdf',
name: meta.name,
size: meta.size,
lastModified: meta.lastModified,
data,
});
}
export async function evictCachedPdf(id: string): Promise<void> {
const { removePdfDocument } = await import('@/lib/client/dexie');
await removePdfDocument(id);
}
export async function getCachedEpub(id: string): Promise<EPUBDocument | null> {
const { getEpubDocument } = await import('@/lib/client/dexie');
return (await getEpubDocument(id)) ?? null;
}
export async function putCachedEpub(meta: BaseDocument, data: ArrayBuffer): Promise<void> {
const { addEpubDocument } = await import('@/lib/client/dexie');
await addEpubDocument({
id: meta.id,
type: 'epub',
name: meta.name,
size: meta.size,
lastModified: meta.lastModified,
data,
});
}
export async function evictCachedEpub(id: string): Promise<void> {
const { removeEpubDocument } = await import('@/lib/client/dexie');
await removeEpubDocument(id);
}
export async function getCachedHtml(id: string): Promise<HTMLDocument | null> {
const { getHtmlDocument } = await import('@/lib/client/dexie');
return (await getHtmlDocument(id)) ?? null;
}
export async function putCachedHtml(meta: BaseDocument, data: string): Promise<void> {
const { addHtmlDocument } = await import('@/lib/client/dexie');
await addHtmlDocument({
id: meta.id,
type: 'html',
name: meta.name,
size: meta.size,
lastModified: meta.lastModified,
data,
});
}
export async function evictCachedHtml(id: string): Promise<void> {
const { removeHtmlDocument } = await import('@/lib/client/dexie');
await removeHtmlDocument(id);
}
export async function evictCachedDocument(id: string): Promise<void> {
await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]);
}
export async function clearDocumentCache(): Promise<void> {
const { clearPdfDocuments, clearEpubDocuments, clearHtmlDocuments } = await import('@/lib/client/dexie');
await Promise.all([clearPdfDocuments(), clearEpubDocuments(), clearHtmlDocuments()]);
const response = await getCachedBlob(stableKey(meta), () => fetchDocumentContentResponse(meta.id, options));
return readDocument(meta, response);
}
export async function cacheStoredDocumentFromBytes(stored: BaseDocument, bytes: ArrayBuffer): Promise<void> {
if (stored.type === 'pdf') {
await putCachedPdf(stored, bytes);
return;
}
if (stored.type === 'epub') {
await putCachedEpub(stored, bytes);
return;
}
if (stored.type === 'html') {
const decoded = new TextDecoder().decode(new Uint8Array(bytes));
await putCachedHtml(stored, decoded);
}
const type = stored.type === 'pdf'
? 'application/pdf'
: stored.type === 'epub'
? 'application/epub+zip'
: 'text/plain; charset=utf-8';
await putCachedBlob(stableKey(stored), new Response(bytes, { status: 200, headers: { 'Content-Type': type } }));
}
export async function ensureCachedDocument(meta: BaseDocument, options?: { signal?: AbortSignal }): Promise<PDFDocument | EPUBDocument | HTMLDocument> {
return ensureCachedDocumentCore(
meta,
{
get: async (m) => {
const { getPdfDocument, getEpubDocument, getHtmlDocument } = await import('@/lib/client/dexie');
if (m.type === 'pdf') return (await getPdfDocument(m.id)) ?? null;
if (m.type === 'epub') return (await getEpubDocument(m.id)) ?? null;
return (await getHtmlDocument(m.id)) ?? null;
},
putPdf: putCachedPdf,
putEpub: putCachedEpub,
putHtml: putCachedHtml,
download: downloadDocumentContent,
decodeText: (buffer) => new TextDecoder().decode(new Uint8Array(buffer)),
},
options,
);
export async function evictCachedDocument(id: string): Promise<void> {
await evictCachedBlobPrefix(`/openreader-cache/documents/${encodeURIComponent(id)}/`);
}
export async function clearDocumentCache(): Promise<void> {
await clearBlobCache();
}

View file

@ -1,24 +1,11 @@
import {
clearDocumentPreviewCache as clearPersistedDocumentPreviewCache,
getDocumentPreviewCache,
putDocumentPreviewCache,
removeDocumentPreviewCache,
} from '@/lib/client/dexie';
import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/client/api/documents';
import { clearBlobCache, getCachedBlob, previewBlobCacheKey } from '@/lib/client/cache/blob-cache';
const inMemoryPreviewUrlCache = new Map<string, string>();
const inFlightPreviewPrime = new Map<string, Promise<string | null>>();
const inFlightPersistedPreviewUrl = new Map<string, Promise<string | null>>();
const PREVIEW_CACHE_SCHEMA_VERSION = 4;
function revokeIfBlobUrl(url: string | null | undefined): void {
if (!url) return;
if (!url.startsWith('blob:')) return;
try {
URL.revokeObjectURL(url);
} catch {
// ignore
}
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
}
export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null {
@ -27,131 +14,62 @@ export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null {
export function setInMemoryDocumentPreviewUrl(cacheKey: string, url: string): void {
const prev = inMemoryPreviewUrlCache.get(cacheKey);
if (prev && prev !== url) {
revokeIfBlobUrl(prev);
}
if (prev && prev !== url) revokeIfBlobUrl(prev);
inMemoryPreviewUrlCache.set(cacheKey, url);
}
export function clearInMemoryDocumentPreviewCache(): void {
for (const value of inMemoryPreviewUrlCache.values()) {
revokeIfBlobUrl(value);
}
for (const value of inMemoryPreviewUrlCache.values()) revokeIfBlobUrl(value);
inMemoryPreviewUrlCache.clear();
inFlightPersistedPreviewUrl.clear();
}
async function fetchPreviewSource(docId: string, signal?: AbortSignal): Promise<Response> {
const options = { signal, cache: 'no-store' as const };
const direct = await fetch(documentPreviewPresignUrl(docId), options).catch(() => null);
if (direct?.ok) return direct;
return fetch(documentPreviewFallbackUrl(docId), options);
}
export async function getPersistedDocumentPreviewUrl(
docId: string,
lastModified: number,
previewVersion: string | number,
cacheKey: string,
): Promise<string | null> {
const cachedUrl = getInMemoryDocumentPreviewUrl(cacheKey);
if (cachedUrl) return cachedUrl;
const persistedKey = `${cacheKey}:${Number(lastModified)}`;
const existing = inFlightPersistedPreviewUrl.get(persistedKey);
if (existing) {
return existing;
}
const promise = (async (): Promise<string | null> => {
const row = await getDocumentPreviewCache(docId);
if (!row) return null;
if (Number((row as { previewVersion?: number }).previewVersion ?? 1) !== PREVIEW_CACHE_SCHEMA_VERSION) {
await removeDocumentPreviewCache(docId).catch(() => {});
return null;
}
if (Number(row.lastModified) !== Number(lastModified)) {
await removeDocumentPreviewCache(docId).catch(() => {});
return null;
}
const latestCachedUrl = getInMemoryDocumentPreviewUrl(cacheKey);
if (latestCachedUrl) return latestCachedUrl;
const contentType = row.contentType || 'image/jpeg';
const bytes = row.data;
if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) {
await removeDocumentPreviewCache(docId).catch(() => {});
return null;
}
const url = URL.createObjectURL(new Blob([bytes], { type: contentType }));
setInMemoryDocumentPreviewUrl(cacheKey, url);
return url;
})();
inFlightPersistedPreviewUrl.set(persistedKey, promise);
try {
return await promise;
} finally {
if (inFlightPersistedPreviewUrl.get(persistedKey) === promise) {
inFlightPersistedPreviewUrl.delete(persistedKey);
}
}
return primeDocumentPreviewCache(docId, previewVersion, cacheKey);
}
export async function primeDocumentPreviewCache(
docId: string,
lastModified: number,
previewVersion: string | number,
cacheKey: string,
options?: { signal?: AbortSignal },
): Promise<string | null> {
const primeKey = `${cacheKey}:${Number(lastModified)}`;
const existingPrime = inFlightPreviewPrime.get(primeKey);
if (existingPrime) {
return existingPrime;
}
const promise = (async (): Promise<string | null> => {
const existing = await getPersistedDocumentPreviewUrl(docId, lastModified, cacheKey);
const memory = getInMemoryDocumentPreviewUrl(cacheKey);
if (memory) return memory;
const primeKey = `${docId}:${previewVersion}`;
const existing = inFlightPreviewPrime.get(primeKey);
if (existing) return existing;
const fetchOptions = {
signal: options?.signal,
cache: 'no-store' as const,
};
// Prefer presign path for priming so healthy direct object access avoids proxy load.
let res = await fetch(documentPreviewPresignUrl(docId), fetchOptions).catch(() => null);
if (!res || !res.ok) {
res = await fetch(documentPreviewFallbackUrl(docId), fetchOptions).catch(() => null);
}
if (!res || !res.ok) return null;
const blob = await res.blob();
const bytes = await blob.arrayBuffer();
if (bytes.byteLength === 0) return null;
const contentType = blob.type || 'image/jpeg';
await putDocumentPreviewCache({
docId,
lastModified: Number(lastModified),
contentType,
data: bytes,
cachedAt: Date.now(),
previewVersion: PREVIEW_CACHE_SCHEMA_VERSION,
});
const url = URL.createObjectURL(new Blob([bytes], { type: contentType }));
const promise = (async () => {
const response = await getCachedBlob(
previewBlobCacheKey(docId, previewVersion),
() => fetchPreviewSource(docId, options?.signal),
).catch(() => null);
if (!response?.ok) return null;
const blob = await response.blob();
if (blob.size === 0) return null;
const url = URL.createObjectURL(blob);
setInMemoryDocumentPreviewUrl(cacheKey, url);
return url;
})();
inFlightPreviewPrime.set(primeKey, promise);
try {
return await promise;
} finally {
if (inFlightPreviewPrime.get(primeKey) === promise) {
inFlightPreviewPrime.delete(primeKey);
}
}
}
export async function clearAllDocumentPreviewCaches(): Promise<void> {
clearInMemoryDocumentPreviewCache();
await clearPersistedDocumentPreviewCache();
await clearBlobCache();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
export type EpubLocation = string | number;
export function isDirectionalEpubLocation(location: EpubLocation): location is 'next' | 'prev' {
return location === 'next' || location === 'prev';
}
export function shouldNavigateToDifferentCfi(
location: EpubLocation,
currentStartCfi: string | undefined,
): location is string {
return typeof location === 'string'
&& !isDirectionalEpubLocation(location)
&& !!currentStartCfi
&& location !== currentStartCfi;
}
export function shouldPersistEpubLocation(
documentId: string | undefined,
previousLocation: EpubLocation,
): documentId is string {
return typeof documentId === 'string' && documentId.length > 0 && previousLocation !== 1;
}

View file

@ -1,11 +1,10 @@
export type OnboardingStep = 'privacy' | 'claim' | 'migration' | 'changelog' | 'done';
export type OnboardingStep = 'privacy' | 'claim' | 'changelog' | 'done';
export type OnboardingStepSnapshot = {
privacyRequired: boolean;
privacyAccepted: boolean;
claimEligible: boolean;
claimHasData: boolean;
migrationRequired: boolean;
changelogPending: boolean;
};
@ -18,10 +17,6 @@ export function resolveNextOnboardingStep(snapshot: OnboardingStepSnapshot): Onb
return 'claim';
}
if (snapshot.migrationRequired) {
return 'migration';
}
if (snapshot.changelogPending) {
return 'changelog';
}

View file

@ -0,0 +1,15 @@
export const queryKeys = {
documents: (sessionId: string) => ['documents', sessionId] as const,
document: (sessionId: string, documentId: string) => ['documents', sessionId, documentId] as const,
preferences: (sessionId: string) => ['preferences', sessionId] as const,
progress: (sessionId: string, documentId: string) => ['progress', sessionId, documentId] as const,
documentSettings: (sessionId: string, documentId: string) => ['document-settings', sessionId, documentId] as const,
onboarding: (sessionId: string) => ['onboarding', sessionId] as const,
folders: (sessionId: string) => ['folders', sessionId] as const,
audiobook: (sessionId: string, bookId: string) => ['audiobook', sessionId, bookId] as const,
ttsManifest: (sessionId: string, documentId: string) => ['tts-manifest', sessionId, documentId] as const,
parsedDocument: (sessionId: string, documentId: string) => ['parsed-document', sessionId, documentId] as const,
claimCounts: (sessionId: string) => ['claim-counts', sessionId] as const,
rateLimit: (sessionId: string) => ['rate-limit', sessionId] as const,
admin: (scope: string) => ['admin', scope] as const,
};

View file

@ -8,7 +8,7 @@ import {
export interface ResolvedTtsCredentials {
/** Provider id passed downstream to TTS generation (one of the 4 built-in IDs). */
provider: string;
/** API key for the request. Empty string when neither admin nor user supplied one. */
/** Decrypted API key from the selected admin-managed provider. */
apiKey: string;
/** Base URL, or undefined to fall through to provider defaults. */
baseUrl: string | undefined;
@ -21,62 +21,26 @@ export interface ResolvedTtsCredentials {
/**
* Resolve TTS credentials for an incoming request.
*
* 1. If `restrictUserApiKeys` is enabled, only admin shared providers are
* used. Built-in provider ids and user-supplied key/base headers are
* ignored.
* 2. If `providerHeader` matches an enabled admin provider slug, use its
* server-stored credentials. Any `x-openai-key` / `x-openai-base-url`
* headers from the client are ignored admin keys must never round-trip
* through the client.
* 3. Otherwise, treat `providerHeader` as a built-in provider id and use the
* per-user `x-openai-key` / `x-openai-base-url` headers as today.
* Only admin-managed shared providers can supply credentials. Built-in
* provider ids select the preferred enabled shared provider of that type.
*
* Returns `null` when the request references a slug that exists but is
* disabled callers should reject with a 4xx.
*/
export async function resolveTtsCredentials(opts: {
providerHeader: string | null;
apiKeyHeader: string | null;
baseUrlHeader: string | null;
fallbackProvider?: string;
restrictUserApiKeys?: boolean;
}): Promise<ResolvedTtsCredentials | { error: 'provider_disabled' | 'provider_unknown' | 'no_shared_provider_configured'; slug: string }> {
const requestedProvider = opts.providerHeader || opts.fallbackProvider || 'openai';
if (opts.restrictUserApiKeys) {
const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider);
const fallback = opts.fallbackProvider || '';
const selected = await resolvePreferredEnabledAdminProvider({
requestedSlug: requestedIsBuiltIn ? null : requestedProvider,
runtimeDefaultSlug: fallback,
});
if (!selected) {
return { error: 'no_shared_provider_configured', slug: requestedProvider };
}
const apiKey = await decryptedKeyFor(selected);
return {
provider: selected.providerType,
apiKey,
baseUrl: selected.baseUrl || undefined,
fromAdmin: true,
adminRecord: selected,
};
}
// Built-in provider ids are not admin slugs — short-circuit.
if (isBuiltInProviderId(requestedProvider)) {
return {
provider: requestedProvider,
apiKey: opts.apiKeyHeader || '',
baseUrl: opts.baseUrlHeader || undefined,
fromAdmin: false,
};
}
// Not a built-in id → try to look it up as an admin slug.
const admin = await getEnabledAdminProviderBySlug(requestedProvider);
const admin = isBuiltInProviderId(requestedProvider)
? await resolvePreferredEnabledAdminProvider({
requestedSlug: null,
runtimeDefaultSlug: opts.fallbackProvider || '',
})
: await getEnabledAdminProviderBySlug(requestedProvider);
if (!admin) {
return { error: 'provider_unknown', slug: requestedProvider };
return { error: 'no_shared_provider_configured', slug: requestedProvider };
}
const apiKey = await decryptedKeyFor(admin);

View file

@ -8,6 +8,8 @@ import {
ttsSegmentVariants,
userPreferences,
userDocumentProgress,
userFolders,
userOnboarding,
} from '@openreader/database/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy';
@ -115,7 +117,7 @@ export async function claimAnonymousData(
options?: { cleanupLegacySources?: boolean },
) {
if (!userId) {
return { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0 };
return { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0, folders: 0, onboarding: 0 };
}
const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([
@ -129,13 +131,16 @@ export async function claimAnonymousData(
.where(eq(audiobooks.userId, unclaimedUserId)) as Promise<Array<{ id: string }>>,
]);
const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed] = await Promise.all([
const foldersClaimed = await transferUserFolders(unclaimedUserId, userId);
const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed, onboardingClaimed] = await Promise.all([
transferUserDocuments(unclaimedUserId, userId, { namespace, transferTts: true }),
transferUserAudiobooks(unclaimedUserId, userId, namespace),
transferUserPreferences(unclaimedUserId, userId),
transferUserProgress(unclaimedUserId, userId),
transferUserDocumentSettings(unclaimedUserId, userId),
transferUserOnboarding(unclaimedUserId, userId),
]);
await db.delete(userFolders).where(eq(userFolders.userId, unclaimedUserId));
if (
options?.cleanupLegacySources !== false
@ -168,9 +173,30 @@ export async function claimAnonymousData(
preferences: preferencesClaimed,
progress: progressClaimed,
documentSettings: documentSettingsClaimed,
folders: foldersClaimed,
onboarding: onboardingClaimed,
};
}
export async function transferUserFolders(fromUserId: string, toUserId: string): Promise<number> {
if (!fromUserId || !toUserId || fromUserId === toUserId) return 0;
const rows = await db.select().from(userFolders).where(eq(userFolders.userId, fromUserId));
for (const row of rows) {
await db.insert(userFolders).values({ ...row, userId: toUserId }).onConflictDoNothing();
}
return rows.length;
}
export async function transferUserOnboarding(fromUserId: string, toUserId: string): Promise<number> {
if (!fromUserId || !toUserId || fromUserId === toUserId) return 0;
const rows = await db.select().from(userOnboarding).where(eq(userOnboarding.userId, fromUserId));
const row = rows[0];
if (!row) return 0;
await db.insert(userOnboarding).values({ ...row, userId: toUserId }).onConflictDoNothing();
await db.delete(userOnboarding).where(eq(userOnboarding.userId, fromUserId));
return 1;
}
/**
* Transfer documents from one userId to another.
*

View file

@ -61,6 +61,8 @@ export type AppendUserExportArchiveInput = {
exportedAtMs: number;
profileData: unknown;
preferences: unknown | null;
folders?: unknown[];
onboarding?: unknown | null;
readingHistory: unknown[];
ttsUsage: unknown[];
jobEvents: unknown[];
@ -144,6 +146,8 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
exportedAtMs,
profileData,
preferences,
folders = [],
onboarding = null,
readingHistory,
ttsUsage,
jobEvents,
@ -171,6 +175,8 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
if (preferences) {
appendJson(archive, 'preferences.json', preferences);
}
appendJson(archive, 'folders.json', folders);
if (onboarding) appendJson(archive, 'onboarding.json', onboarding);
appendJson(archive, 'reading_history.json', readingHistory);
appendJson(archive, 'tts_usage.json', ttsUsage);
appendJson(archive, 'job_events.json', jobEvents);

View file

@ -125,6 +125,11 @@ export function sanitizePreferencesPatch(
case 'savedVoices':
out[key] = sanitizeSavedVoices(value);
break;
case 'documentListState':
if (isRecord(value)) {
out[key] = { ...value, folders: [] } as unknown as SyncedPreferencesPatch[typeof key];
}
break;
default:
break;
}

View file

@ -1,52 +0,0 @@
import { normalizeVersion } from '@/lib/shared/changelog';
export const USER_PREFERENCES_META_KEY = '_meta';
export const USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY = 'lastSeenAppVersion';
export type UserPreferencesMeta = Record<string, unknown> & {
lastSeenAppVersion?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function deserializeUserPreferencesPayload(value: unknown): Record<string, unknown> {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
return isRecord(value) ? value : {};
}
export function extractUserPreferencesMeta(payload: unknown): UserPreferencesMeta {
const record = deserializeUserPreferencesPayload(payload);
const rawMeta = record[USER_PREFERENCES_META_KEY];
if (!isRecord(rawMeta)) return {};
const out: UserPreferencesMeta = { ...rawMeta };
const rawLastSeen = out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY];
if (typeof rawLastSeen === 'string') {
out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY] = normalizeVersion(rawLastSeen);
} else {
delete out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY];
}
return out;
}
export function withUserPreferencesMeta(
payload: Record<string, unknown>,
meta: UserPreferencesMeta,
): Record<string, unknown> {
const out = { ...payload };
delete out[USER_PREFERENCES_META_KEY];
if (Object.keys(meta).length > 0) {
out[USER_PREFERENCES_META_KEY] = meta;
}
return out;
}

View file

@ -1,45 +0,0 @@
export type OnboardingStorageScope = 'local-dexie' | 'server-user-preferences' | 'hybrid';
export type OnboardingStateDescriptor = {
/**
* Where this state is persisted today.
* - local-dexie: browser/device-local only
* - server-user-preferences: per-user on server
* - hybrid: intentionally persisted in both places
*/
scope: OnboardingStorageScope;
/**
* Optional local key name when state is stored in Dexie app-config.
*/
localKey?: string;
/**
* Optional server-side key when state is stored in user preferences/meta.
*/
serverKey?: string;
};
/**
* Central registry for onboarding-related state and where each item lives.
* Keep this map up to date whenever onboarding persistence changes.
*/
export const ONBOARDING_STATE_REGISTRY = {
privacyAccepted: {
scope: 'local-dexie',
localKey: 'privacyAccepted',
},
firstVisitSettingsOpened: {
scope: 'local-dexie',
localKey: 'firstVisit',
},
documentsMigrationPrompted: {
scope: 'local-dexie',
localKey: 'documentsMigrationPrompted',
},
changelogLastSeenAppVersion: {
scope: 'server-user-preferences',
serverKey: '_meta.lastSeenAppVersion',
},
} as const satisfies Record<string, OnboardingStateDescriptor>;
export type OnboardingStateKey = keyof typeof ONBOARDING_STATE_REGISTRY;

View file

@ -29,8 +29,6 @@ export function clampTtsSegmentMaxBlockLength(value: number | undefined | null):
}
export interface AppConfigValues {
apiKey: string;
baseUrl: string;
viewType: ViewType;
voiceSpeed: number;
audioPlayerSpeed: number;
@ -55,10 +53,7 @@ export interface AppConfigValues {
epubWordHighlightEnabled: boolean;
htmlHighlightEnabled: boolean;
htmlWordHighlightEnabled: boolean;
firstVisit: boolean;
documentListState: DocumentListState;
privacyAccepted: boolean;
documentsMigrationPrompted: boolean;
}
/**
@ -75,8 +70,6 @@ export function getAppConfigDefaults(): AppConfigValues {
// provider id (the old 'custom-openai') into every user's config, since that
// value isn't actually selectable in shared-provider mode.
return {
apiKey: '',
baseUrl: '',
viewType: 'single',
voiceSpeed: 1,
audioPlayerSpeed: 1,
@ -101,7 +94,6 @@ export function getAppConfigDefaults(): AppConfigValues {
epubWordHighlightEnabled: true,
htmlHighlightEnabled: true,
htmlWordHighlightEnabled: true,
firstVisit: false,
documentListState: {
sortBy: 'name',
sortDirection: 'asc',
@ -110,8 +102,6 @@ export function getAppConfigDefaults(): AppConfigValues {
showHint: true,
viewMode: 'grid',
},
privacyAccepted: false,
documentsMigrationPrompted: false,
};
}

View file

@ -6,6 +6,7 @@ export interface BaseDocument {
size: number;
lastModified: number;
recentlyOpenedAt?: number;
contentVersion?: string;
type: DocumentType;
scope?: 'user';
folderId?: string;

View file

@ -25,6 +25,7 @@ export const SYNCED_PREFERENCE_KEYS = [
'epubWordHighlightEnabled',
'htmlHighlightEnabled',
'htmlWordHighlightEnabled',
'documentListState',
] as const;
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];

View file

@ -84,7 +84,7 @@ test.describe('Document folders and hint persistence', () => {
// Hint should disappear
await expect(hint).toHaveCount(0);
// Ensure the dismissal has been persisted to IndexedDB before reloading
// Ensure the dismissal has been persisted to server preferences before reloading
await waitForDocumentListHintPersist(page, false);
// Reload and ensure it remains dismissed

View file

@ -533,27 +533,14 @@ export async function triggerViewportResize(page: Page, width: number, height: n
await page.setViewportSize({ width, height });
}
// Wait for DocumentListState.showHint to persist in IndexedDB 'app-config' store
// Wait for DocumentListState.showHint to persist in server preferences.
export async function waitForDocumentListHintPersist(page: Page, expected: boolean) {
await page.waitForFunction(async (exp) => {
try {
const openDb = () => new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open('openreader-db');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const db = await openDb();
const readConfig = () => new Promise<unknown>((resolve, reject) => {
const tx = db.transaction(['app-config'], 'readonly');
const store = tx.objectStore('app-config');
const getReq = store.get('singleton');
getReq.onsuccess = () => resolve(getReq.result);
getReq.onerror = () => reject(getReq.error);
});
const item = await readConfig();
db.close();
if (!item || typeof item !== 'object') return false;
const state = (item as { documentListState?: unknown }).documentListState;
const response = await fetch('/api/user/state/preferences', { cache: 'no-store' });
if (!response.ok) return false;
const item = await response.json() as { preferences?: { documentListState?: unknown } };
const state = item.preferences?.documentListState;
if (!state || typeof state !== 'object') return false;
const showHint = (state as { showHint?: unknown }).showHint;
return typeof showHint === 'boolean' && showHint === exp;

View file

@ -0,0 +1,74 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
audioBlobCacheKey,
documentBlobCacheKey,
getCachedBlob,
previewBlobCacheKey,
} from '../../src/lib/client/cache/blob-cache';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('blob cache stable keys', () => {
test('builds versioned synthetic keys', () => {
expect(documentBlobCacheKey('doc/id', 'v1')).toBe('/openreader-cache/documents/doc%2Fid/v1');
expect(previewBlobCacheKey('doc', 'etag/1')).toBe('/openreader-cache/previews/doc/etag%2F1');
expect(audioBlobCacheKey('audio/key', 2)).toBe('/openreader-cache/audio/audio%2Fkey/2');
});
test('returns a cache hit without fetching', async () => {
const cached = new Response('cached');
const match = vi.fn().mockResolvedValue(cached);
vi.stubGlobal('window', {});
vi.stubGlobal('caches', { open: vi.fn().mockResolvedValue({ match, put: vi.fn() }) });
const fetchSource = vi.fn();
expect(await (await getCachedBlob('/openreader-cache/documents/doc/v1', fetchSource)).text()).toBe('cached');
expect(fetchSource).not.toHaveBeenCalled();
});
test('fetches and caches a successful full response on a miss', async () => {
const put = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('window', {});
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue(undefined), put }),
});
const response = await getCachedBlob('/openreader-cache/documents/doc/v2', async () => new Response('network'));
expect(await response.text()).toBe('network');
expect(put).toHaveBeenCalledOnce();
});
test('treats missing Cache Storage and cache write failures as non-fatal', async () => {
vi.stubGlobal('window', {});
vi.stubGlobal('caches', {
open: vi.fn()
.mockRejectedValueOnce(new Error('unavailable'))
.mockResolvedValueOnce({
match: vi.fn().mockResolvedValue(undefined),
put: vi.fn().mockRejectedValue(new Error('quota')),
}),
});
expect(await (await getCachedBlob('/openreader-cache/documents/doc/v3', async () => new Response('first'))).text()).toBe('first');
expect(await (await getCachedBlob('/openreader-cache/documents/doc/v4', async () => new Response('second'))).text()).toBe('second');
});
test('does not cache partial responses and throws for failed fetches', async () => {
const put = vi.fn();
vi.stubGlobal('window', {});
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue(undefined), put }),
});
const partial = await getCachedBlob(
'/openreader-cache/audio/key/v1',
async () => new Response('partial', { status: 206, headers: { 'Content-Range': 'bytes 0-6/20' } }),
);
expect(partial.status).toBe(206);
expect(put).not.toHaveBeenCalled();
await expect(getCachedBlob('/openreader-cache/audio/key/v2', async () => new Response(null, { status: 404 })))
.rejects.toThrow('Blob fetch failed: 404');
});
});

View file

@ -1,114 +0,0 @@
import { describe, expect, test } from 'vitest';
import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '../../src/types/documents';
import { ensureCachedDocumentCore } from '../../src/lib/client/cache/documents';
import { makeBaseDocument } from './support/document-fixtures';
describe('document-cache-core', () => {
test('returns cached PDF without downloading', async () => {
const meta: BaseDocument = makeBaseDocument({
id: 'pdf1',
name: 'a.pdf',
size: 10,
lastModified: 1_700_000_000_001,
type: 'pdf',
});
let downloads = 0;
const cached: PDFDocument = { ...meta, type: 'pdf', data: new ArrayBuffer(1) };
const result = await ensureCachedDocumentCore(meta, {
get: async () => cached,
putPdf: async () => { throw new Error('should not put'); },
putEpub: async () => { throw new Error('should not put'); },
putHtml: async () => { throw new Error('should not put'); },
download: async () => {
downloads++;
return new ArrayBuffer(0);
},
decodeText: () => '',
});
expect(downloads).toBe(0);
expect(result.type).toBe('pdf');
});
test('downloads and stores on cache miss (EPUB)', async () => {
const meta: BaseDocument = makeBaseDocument({
id: 'epub1',
name: 'b.epub',
size: 10,
lastModified: 1_700_000_000_002,
type: 'epub',
});
const store = new Map<string, PDFDocument | EPUBDocument | HTMLDocument>();
let downloads = 0;
const result = await ensureCachedDocumentCore(meta, {
get: async (m) => store.get(m.id) ?? null,
putPdf: async () => { /* unused */ },
putEpub: async (m, data) => {
store.set(m.id, { ...m, type: 'epub', data } as EPUBDocument);
},
putHtml: async () => { /* unused */ },
download: async () => {
downloads++;
return new Uint8Array([1, 2, 3]).buffer;
},
decodeText: () => '',
});
expect(downloads).toBe(1);
expect(result.type).toBe('epub');
expect((result as EPUBDocument).data.byteLength).toBe(3);
});
test('downloads, decodes, and stores HTML on cache miss', async () => {
const meta: BaseDocument = makeBaseDocument({
id: 'html1',
name: 'c.txt',
size: 5,
lastModified: 1_700_000_000_003,
type: 'html',
});
const store = new Map<string, PDFDocument | EPUBDocument | HTMLDocument>();
let decodedCalls = 0;
const result = await ensureCachedDocumentCore(meta, {
get: async (m) => store.get(m.id) ?? null,
putPdf: async () => { /* unused */ },
putEpub: async () => { /* unused */ },
putHtml: async (m, data) => {
store.set(m.id, { ...m, type: 'html', data } as HTMLDocument);
},
download: async () => new TextEncoder().encode('hello').buffer,
decodeText: (buf) => {
decodedCalls++;
return new TextDecoder().decode(new Uint8Array(buf));
},
});
expect(decodedCalls).toBe(1);
expect(result.type).toBe('html');
expect((result as HTMLDocument).data).toBe('hello');
});
test('throws deterministic cache error when download succeeds but backend misses persisted PDF', async () => {
const meta = makeBaseDocument({
id: 'pdf-cache-miss',
type: 'pdf',
});
await expect(
ensureCachedDocumentCore(meta, {
get: async () => null,
putPdf: async () => { /* simulate write path without persisted row */ },
putEpub: async () => { /* unused */ },
putHtml: async () => { /* unused */ },
download: async () => new Uint8Array([1, 2, 3]).buffer,
decodeText: () => '',
}),
).rejects.toThrow('Failed to cache PDF');
});
});

View file

@ -4,7 +4,7 @@ import {
isDirectionalEpubLocation,
shouldNavigateToDifferentCfi,
shouldPersistEpubLocation,
} from '../../src/hooks/epub/useEPUBLocationController';
} from '../../src/lib/client/epub/location-controller';
describe('EPUB location controller helpers', () => {
test('detects directional locations', () => {

View file

@ -9,7 +9,6 @@ describe('onboarding flow resolver', () => {
privacyAccepted: false,
claimEligible: true,
claimHasData: true,
migrationRequired: true,
changelogPending: true,
});
@ -22,33 +21,18 @@ describe('onboarding flow resolver', () => {
privacyAccepted: true,
claimEligible: true,
claimHasData: true,
migrationRequired: true,
changelogPending: true,
});
expect(step).toBe('claim');
});
test('resolves migration when no claim is needed', () => {
const step = resolveNextOnboardingStep({
privacyRequired: true,
privacyAccepted: true,
claimEligible: true,
claimHasData: false,
migrationRequired: true,
changelogPending: true,
});
expect(step).toBe('migration');
});
test('resolves changelog when prior steps are clear', () => {
const step = resolveNextOnboardingStep({
privacyRequired: true,
privacyAccepted: true,
claimEligible: true,
claimHasData: false,
migrationRequired: false,
changelogPending: true,
});
@ -61,7 +45,6 @@ describe('onboarding flow resolver', () => {
privacyAccepted: true,
claimEligible: true,
claimHasData: false,
migrationRequired: false,
changelogPending: false,
});
const minimalStep = resolveNextOnboardingStep({
@ -69,7 +52,6 @@ describe('onboarding flow resolver', () => {
privacyAccepted: false,
claimEligible: false,
claimHasData: false,
migrationRequired: false,
changelogPending: false,
});

View file

@ -1,19 +0,0 @@
import { describe, expect, test } from 'vitest';
import { ONBOARDING_STATE_REGISTRY } from '../../src/lib/shared/onboarding-state';
import { SYNCED_PREFERENCE_KEYS } from '../../src/types/user-state';
describe('onboarding state storage scopes', () => {
test('keeps local onboarding flags out of synced server preferences', () => {
expect(ONBOARDING_STATE_REGISTRY.privacyAccepted.scope).toBe('local-dexie');
expect(ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.scope).toBe('local-dexie');
expect(ONBOARDING_STATE_REGISTRY.documentsMigrationPrompted.scope).toBe('local-dexie');
expect(ONBOARDING_STATE_REGISTRY.changelogLastSeenAppVersion.scope).toBe('server-user-preferences');
const synced = new Set<string>(SYNCED_PREFERENCE_KEYS);
expect(synced.has(ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey!)).toBe(false);
expect(synced.has(ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey!)).toBe(false);
expect(synced.has(ONBOARDING_STATE_REGISTRY.documentsMigrationPrompted.localKey!)).toBe(false);
});
});

View file

@ -0,0 +1,19 @@
import { describe, expect, test } from 'vitest';
import { queryKeys } from '../../src/lib/client/query-keys';
describe('query keys', () => {
test('isolates server state by session and document', () => {
expect(queryKeys.documents('user-a')).not.toEqual(queryKeys.documents('user-b'));
expect(queryKeys.progress('user-a', 'doc-a')).not.toEqual(queryKeys.progress('user-a', 'doc-b'));
expect(queryKeys.documentSettings('user-a', 'doc-a')).not.toEqual(queryKeys.documentSettings('user-b', 'doc-a'));
});
test('defines centralized keys for migrated server-state domains', () => {
expect(queryKeys.preferences('user')).toEqual(['preferences', 'user']);
expect(queryKeys.onboarding('user')).toEqual(['onboarding', 'user']);
expect(queryKeys.folders('user')).toEqual(['folders', 'user']);
expect(queryKeys.audiobook('user', 'book')).toEqual(['audiobook', 'user', 'book']);
expect(queryKeys.ttsManifest('user', 'doc')).toEqual(['tts-manifest', 'user', 'doc']);
expect(queryKeys.parsedDocument('user', 'doc')).toEqual(['parsed-document', 'user', 'doc']);
});
});

View file

@ -25,6 +25,8 @@ describe('transferUserDocuments', () => {
size INTEGER NOT NULL,
last_modified INTEGER NOT NULL,
file_path TEXT NOT NULL,
folder_id TEXT,
recently_opened_at INTEGER,
parse_state TEXT,
parsed_json_key TEXT,
created_at INTEGER,
@ -149,6 +151,8 @@ describe('transferUserDocuments', () => {
size INTEGER NOT NULL,
last_modified INTEGER NOT NULL,
file_path TEXT NOT NULL,
folder_id TEXT,
recently_opened_at INTEGER,
parse_state TEXT,
parsed_json_key TEXT,
created_at INTEGER,

View file

@ -533,7 +533,6 @@ describe('config helpers', () => {
test('builds synced preference patches and honors non-default filtering', () => {
expect(buildSyncedPreferencePatch({
voiceSpeed: 1.2,
baseUrl: 'http://localhost',
ttsModel: 'kokoro',
})).toEqual({
voiceSpeed: 1.2,

View file

@ -1,81 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
cancelPendingPreferenceSync,
flushUserPreferencesSync,
scheduleUserPreferencesSync,
} from '../../src/lib/client/api/user-state';
type FetchMock = ReturnType<typeof vi.fn> & { calls: Array<{ url: string; init?: RequestInit }> };
function installFetchMock(responder: () => Response): FetchMock {
const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
mock.calls.push({ url: String(input), init });
return responder();
}) as unknown as FetchMock;
mock.calls = [];
global.fetch = mock as unknown as typeof fetch;
return mock;
}
function okResponse(): Response {
return new Response(
JSON.stringify({ preferences: {}, clientUpdatedAtMs: Date.now(), applied: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
describe('flushUserPreferencesSync', () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.useFakeTimers();
cancelPendingPreferenceSync();
});
afterEach(() => {
cancelPendingPreferenceSync();
vi.useRealTimers();
global.fetch = originalFetch;
});
test('sends a pending debounced patch immediately instead of waiting for the timer', async () => {
const fetchMock = installFetchMock(okResponse);
scheduleUserPreferencesSync({ providerRef: 'shared-b', providerType: 'openai' }, 'user-1');
// Nothing should have fired yet — it's still debounced.
expect(fetchMock.calls).toHaveLength(0);
await flushUserPreferencesSync();
expect(fetchMock.calls).toHaveLength(1);
const { url, init } = fetchMock.calls[0];
expect(url).toContain('/api/user/state/preferences');
expect(init?.method).toBe('PUT');
const body = JSON.parse(String(init?.body));
expect(body.patch.providerRef).toBe('shared-b');
expect(body.patch.providerType).toBe('openai');
// The debounce timer must not also fire a duplicate request afterwards.
await vi.runAllTimersAsync();
expect(fetchMock.calls).toHaveLength(1);
});
test('is a no-op when there is no pending patch', async () => {
const fetchMock = installFetchMock(okResponse);
await flushUserPreferencesSync();
expect(fetchMock.calls).toHaveLength(0);
});
test('rejects when the server write fails so callers can surface the error', async () => {
installFetchMock(() => new Response(JSON.stringify({ error: 'boom' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
}));
scheduleUserPreferencesSync({ providerRef: 'shared-b' }, 'user-1');
await expect(flushUserPreferencesSync()).rejects.toThrow();
});
});

View file

@ -1,14 +1,9 @@
import { test, expect } from '@playwright/test';
import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers';
interface HtmlDocumentRow {
id?: string;
data?: string;
}
type HashCheckResult =
| { ok: true; storedId: string; computedId: string }
| { ok: false; reason: 'Missing stored html document' | 'Hash mismatch'; storedId?: string; computedId?: string };
| { ok: false; reason: 'Missing stored html document' | 'Hash mismatch' | 'Content fetch failed'; storedId?: string; computedId?: string };
test.describe('Document Upload Tests', () => {
test.beforeEach(async ({ page }, testInfo) => {
@ -58,38 +53,24 @@ test.describe('Document Upload Tests', () => {
await expectDocumentListed(page, 'sample.txt');
const result = await page.evaluate<HashCheckResult>(async () => {
const idb = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('openreader-db');
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
const listRes = await fetch('/api/documents', { cache: 'no-store' });
const docs = listRes.ok
? ((await listRes.json()) as { documents?: Array<{ id: string; name: string }> }).documents ?? []
: [];
const doc = docs.find((item) => item.name === 'sample.txt');
if (!doc?.id) return { ok: false, reason: 'Missing stored html document' as const };
try {
const docs = await new Promise<HtmlDocumentRow[]>((resolve, reject) => {
const tx = idb.transaction('html-documents', 'readonly');
const store = tx.objectStore('html-documents');
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result as HtmlDocumentRow[]);
});
if (!docs[0]?.data || !docs[0]?.id) {
return { ok: false, reason: 'Missing stored html document' as const };
}
const bytes = new TextEncoder().encode(String(docs[0].data));
const digest = await crypto.subtle.digest('SHA-256', bytes);
const contentRes = await fetch(`/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`, { cache: 'no-store' });
if (!contentRes.ok) return { ok: false, reason: 'Content fetch failed' as const, storedId: doc.id };
const digest = await crypto.subtle.digest('SHA-256', await contentRes.arrayBuffer());
const computedId = Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
if (computedId === docs[0].id) {
return { ok: true as const, storedId: docs[0].id as string, computedId };
}
return { ok: false as const, reason: 'Hash mismatch', storedId: docs[0].id as string, computedId };
} finally {
idb.close();
if (computedId === doc.id) {
return { ok: true as const, storedId: doc.id, computedId };
}
return { ok: false as const, reason: 'Hash mismatch', storedId: doc.id, computedId };
});
const detail = result.ok