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 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 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`: 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` - Interactions: `react-dnd`, `react-dropzone`
- Server state: [TanStack Query](https://tanstack.com/query) (React Query v5) - Server state: [TanStack Query](https://tanstack.com/query) (React Query v5)
- Authentication: [Better Auth](https://www.better-auth.com/) client SDK - 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/) - Audio playback: [Howler.js](https://howlerjs.com/)
- Notifications: `react-hot-toast` - Notifications: `react-hot-toast`
- Document rendering: - Document rendering:

View file

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

View file

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

View file

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

View file

@ -24,8 +24,8 @@ describe('orphan recovery', () => {
}); });
const firstSweep = await recoverOrphanedOperations({ const firstSweep = await recoverOrphanedOperations({
operationStateStore: fake.deps.operationStateStore!, operationStateStore: fake.deps.operationStateStore,
orchestrator: fake.deps.orchestrator!, orchestrator: fake.deps.orchestrator,
whisperTimeoutMs: 30_000, whisperTimeoutMs: 30_000,
pdfTimeoutMs: 300_000, pdfTimeoutMs: 300_000,
opStaleMs: 1_800_000, opStaleMs: 1_800_000,
@ -38,8 +38,8 @@ describe('orphan recovery', () => {
vi.advanceTimersByTime(31_000); vi.advanceTimersByTime(31_000);
const secondSweep = await recoverOrphanedOperations({ const secondSweep = await recoverOrphanedOperations({
operationStateStore: fake.deps.operationStateStore!, operationStateStore: fake.deps.operationStateStore,
orchestrator: fake.deps.orchestrator!, orchestrator: fake.deps.orchestrator,
whisperTimeoutMs: 30_000, whisperTimeoutMs: 30_000,
pdfTimeoutMs: 300_000, pdfTimeoutMs: 300_000,
opStaleMs: 1_800_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, "when": 1780851107819,
"tag": "0011_scheduled-tasks", "tag": "0011_scheduled-tasks",
"breakpoints": true "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, "when": 1780851107505,
"tag": "0011_scheduled-tasks", "tag": "0011_scheduled-tasks",
"breakpoints": true "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. // 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 documents = usePostgres ? postgresSchema.documents : sqliteSchema.documents;
export const userFolders = usePostgres ? postgresSchema.userFolders : sqliteSchema.userFolders;
export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema.audiobooks; export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema.audiobooks;
export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters; export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters;
export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars; export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars;
export const userJobEvents = usePostgres ? postgresSchema.userJobEvents : sqliteSchema.userJobEvents; export const userJobEvents = usePostgres ? postgresSchema.userJobEvents : sqliteSchema.userJobEvents;
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences; 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 documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings;
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress; export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;
export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews; 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`; 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', { export const documents = pgTable('documents', {
id: text('id').notNull(), id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), 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(), size: bigint('size', { mode: 'number' }).notNull(),
lastModified: bigint('last_modified', { mode: 'number' }).notNull(), lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
filePath: text('file_path').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), createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [ }, (table) => [
primaryKey({ columns: [table.id, table.userId] }), 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').on(table.userId),
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified), 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', { 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), 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', { export const documentSettings = pgTable('document_settings', {
documentId: text('document_id').notNull(), documentId: text('document_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), 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))`; 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', { export const documents = sqliteTable('documents', {
id: text('id').notNull(), id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
@ -12,11 +24,19 @@ export const documents = sqliteTable('documents', {
size: integer('size').notNull(), size: integer('size').notNull(),
lastModified: integer('last_modified').notNull(), lastModified: integer('last_modified').notNull(),
filePath: text('file_path').notNull(), filePath: text('file_path').notNull(),
folderId: text('folder_id'),
recentlyOpenedAt: integer('recently_opened_at'),
createdAt: integer('created_at').default(SQLITE_NOW_MS), createdAt: integer('created_at').default(SQLITE_NOW_MS),
}, (table) => [ }, (table) => [
primaryKey({ columns: [table.id, table.userId] }), 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').on(table.userId),
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified), 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', { export const audiobooks = sqliteTable('audiobooks', {
@ -89,6 +109,14 @@ export const userPreferences = sqliteTable('user_preferences', {
updatedAt: integer('updated_at').default(SQLITE_NOW_MS), 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', { export const documentSettings = sqliteTable('document_settings', {
documentId: text('document_id').notNull(), documentId: text('document_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),

View file

@ -59,12 +59,6 @@ importers:
core-js: core-js:
specifier: ^3.49.0 specifier: ^3.49.0
version: 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: dotenv:
specifier: ^17.4.2 specifier: ^17.4.2
version: 17.4.2 version: 17.4.2
@ -2606,15 +2600,6 @@ packages:
devlop@1.1.0: devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} 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: didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@ -7089,13 +7074,6 @@ snapshots:
dependencies: dependencies:
dequal: 2.0.3 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: {} didyoumean@1.2.2: {}
dlv@1.1.3: {} dlv@1.1.3: {}

View file

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

View file

@ -115,8 +115,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
} = useTTS(); } = useTTS();
// Configuration context to get TTS settings // Configuration context to get TTS settings
const { const {
apiKey,
baseUrl,
providerRef, providerRef,
ttsSegmentMaxBlockLength, ttsSegmentMaxBlockLength,
epubTheme, epubTheme,
@ -195,7 +193,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
}, [resetHighlightState, setCurrDocPages, stop]); }, [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 * @param {string} id - The unique identifier of the document
* @throws {Error} When document data is empty or retrieval fails * @throws {Error} When document data is empty or retrieval fails
*/ */
@ -478,8 +476,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({ const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({
bookRef, bookRef,
tocRef, tocRef,
apiKey,
baseUrl,
providerRef, providerRef,
}); });

View file

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

View file

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

View file

@ -34,12 +34,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
allowAnonymousAuthSessions={allowAnonymousAuthSessions} allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled} githubAuthEnabled={githubAuthEnabled}
> >
{/* ConfigProvider lives here, in the shared (app) layout, so it stays {/* Keep the preferences query/context mounted across library and reader navigation. */}
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. */}
<ConfigProvider> <ConfigProvider>
<AppShell> <AppShell>
<AppMain>{children}</AppMain> <AppMain>{children}</AppMain>

View file

@ -14,7 +14,6 @@ import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client'; import type { AudiobookGenerationSettings } from '@/types/client';
import TTSPlayer from '@/components/player/TTSPlayer'; import TTSPlayer from '@/components/player/TTSPlayer';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { resolveDocumentId } from '@/lib/client/dexie';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
@ -99,7 +98,6 @@ export default function PDFViewerPage() {
const loadDocument = useCallback(async () => { const loadDocument = useCallback(async () => {
if (!isLoading) return; // Prevent calls when not loading new doc if (!isLoading) return; // Prevent calls when not loading new doc
console.log('Loading new document (from page.tsx)'); console.log('Loading new document (from page.tsx)');
let didRedirect = false;
let startedLoad = false; let startedLoad = false;
let loadSucceeded = false; let loadSucceeded = false;
try { try {
@ -107,12 +105,7 @@ export default function PDFViewerPage() {
setError('Document not found'); setError('Document not found');
return; return;
} }
const resolved = await resolveDocumentId(id as string); const resolved = id as string;
if (resolved !== (id as string)) {
didRedirect = true;
router.replace(`/pdf/${resolved}`);
return;
}
if (loadedDocIdRef.current === resolved) { if (loadedDocIdRef.current === resolved) {
return; return;
@ -125,12 +118,18 @@ export default function PDFViewerPage() {
inFlightDocIdRef.current = resolved; inFlightDocIdRef.current = resolved;
stop(); // Reset TTS when loading new document stop(); // Reset TTS when loading new document
for (let attempt = 0; attempt < 2; attempt += 1) { for (let attempt = 0; attempt < 2; attempt += 1) {
const loaded = await setCurrentDocument(resolved); const result = await setCurrentDocument(resolved);
if (loaded) { if (result === 'loaded') {
loadSucceeded = true; loadSucceeded = true;
loadedDocIdRef.current = resolved; loadedDocIdRef.current = resolved;
break; 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) { if (attempt === 0) {
await new Promise((resolve) => setTimeout(resolve, 250)); await new Promise((resolve) => setTimeout(resolve, 250));
} }
@ -145,11 +144,11 @@ export default function PDFViewerPage() {
if (startedLoad) { if (startedLoad) {
inFlightDocIdRef.current = null; inFlightDocIdRef.current = null;
} }
if (!didRedirect && startedLoad && loadSucceeded) { if (startedLoad && loadSucceeded) {
setIsLoading(false); setIsLoading(false);
} }
} }
}, [isLoading, id, router, setCurrentDocument, stop]); }, [isLoading, id, setCurrentDocument, stop]);
useEffect(() => { useEffect(() => {
loadDocument(); loadDocument();

View file

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

View file

@ -477,10 +477,7 @@ export async function POST(request: NextRequest) {
providerForError = requestedProvider; providerForError = requestedProvider;
const credResolved = await resolveTtsCredentials({ const credResolved = await resolveTtsCredentials({
providerHeader: requestedProvider, providerHeader: requestedProvider,
apiKeyHeader: request.headers.get('x-openai-key'),
baseUrlHeader: request.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider, fallbackProvider: runtimeConfig.defaultTtsProvider,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
}); });
if ('error' in credResolved) { if ('error' in credResolved) {
if (credResolved.error === 'no_shared_provider_configured') { 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', status: 'ready',
presignUrl, presignUrl,
fallbackUrl, fallbackUrl,
previewVersion: preview.eTag || String(doc.lastModified),
...(directUrl ? { directUrl } : {}), ...(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; size: number;
lastModified: number; lastModified: number;
filePath: string; filePath: string;
folderId: string | null;
recentlyOpenedAt: number | null;
}>; }>;
const results: BaseDocument[] = rows.map((doc) => { const results: BaseDocument[] = rows.map((doc) => {
@ -75,6 +77,9 @@ export async function GET(req: NextRequest) {
lastModified: Number(doc.lastModified), lastModified: Number(doc.lastModified),
type, type,
scope: 'user', 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({ const requestCreds = await resolveTtsCredentials({
providerHeader: parsed.settings.providerRef, providerHeader: parsed.settings.providerRef,
apiKeyHeader: request.headers.get('x-openai-key'),
baseUrlHeader: request.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider, fallbackProvider: runtimeConfig.defaultTtsProvider,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
}); });
if ('error' in requestCreds) { if ('error' in requestCreds) {
const status = requestCreds.error === 'no_shared_provider_configured' ? 503 : 404; 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 runtimeConfig = await getResolvedRuntimeConfig();
const resolved = await resolveTtsCredentials({ const resolved = await resolveTtsCredentials({
providerHeader: req.headers.get('x-tts-provider'), 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, fallbackProvider: runtimeConfig.defaultTtsProvider,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
}); });
if ('error' in resolved) { 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 { claimAnonymousData } from '@/lib/server/user/claim-data';
import { auth } from '@/lib/server/auth/auth'; import { auth } from '@/lib/server/auth/auth';
import { db } from '@openreader/database'; 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 { count, eq, ne } from 'drizzle-orm';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
@ -26,14 +26,16 @@ async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
async function getClaimableCounts( async function getClaimableCounts(
unclaimedUserId: string, unclaimedUserId: string,
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number }> { ): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number; folders: number; onboarding: number }> {
const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount]] = const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount], [folderCount], [onboardingCount]] =
await Promise.all([ await Promise.all([
db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)), 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(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)),
db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.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(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)),
db.select({ count: count() }).from(documentSettings).where(eq(documentSettings.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 { return {
@ -42,6 +44,8 @@ async function getClaimableCounts(
preferences: Number(preferencesCount?.count ?? 0), preferences: Number(preferencesCount?.count ?? 0),
progress: Number(progressCount?.count ?? 0), progress: Number(progressCount?.count ?? 0),
documentSettings: Number(settingsCount?.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, userDocumentProgress,
userJobEvents, userJobEvents,
userPreferences, userPreferences,
userFolders,
userOnboarding,
userTtsChars, userTtsChars,
} from '@openreader/database/schema'; } from '@openreader/database/schema';
import * as authSchemaSqlite from '@openreader/database/schema-auth-sqlite'; import * as authSchemaSqlite from '@openreader/database/schema-auth-sqlite';
@ -65,6 +67,8 @@ export async function GET(req: NextRequest) {
segmentVariants, segmentVariants,
userDocs, userDocs,
userAudiobooks, userAudiobooks,
folders,
onboarding,
] = await Promise.all([ ] = await Promise.all([
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1), db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1),
db db
@ -107,6 +111,8 @@ export async function GET(req: NextRequest) {
.from(audiobooks) .from(audiobooks)
.where(eq(audiobooks.userId, userId)) .where(eq(audiobooks.userId, userId))
.orderBy(desc(audiobooks.createdAt)), .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; const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
@ -190,6 +196,8 @@ export async function GET(req: NextRequest) {
exportedAtMs, exportedAtMs,
profileData, profileData,
preferences: prefs[0] ?? null, preferences: prefs[0] ?? null,
folders,
onboarding: onboarding[0] ?? null,
readingHistory: progress, readingHistory: progress,
ttsUsage, ttsUsage,
jobEvents, jobEvents,

View file

@ -1,26 +1,15 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@openreader/database'; 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 { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog';
import { nowTimestampMs } from '@/lib/shared/timestamps'; import { nowTimestampMs } from '@/lib/shared/timestamps';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response'; 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'; 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) { export async function POST(req: NextRequest) {
try { try {
const scope = await resolveUserStateScope(req); const scope = await resolveUserStateScope(req);
@ -38,40 +27,28 @@ export async function POST(req: NextRequest) {
const rows = await db const rows = await db
.select({ .select({
dataJson: userPreferences.dataJson, lastSeenAppVersion: userOnboarding.lastSeenAppVersion,
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
}) })
.from(userPreferences) .from(userOnboarding)
.where(eq(userPreferences.userId, scope.ownerUserId)) .where(eq(userOnboarding.userId, scope.ownerUserId))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
const existingPayload = deserializeUserPreferencesPayload(row?.dataJson); const lastSeenVersion = row?.lastSeenAppVersion ?? null;
const existingMeta = extractUserPreferencesMeta(existingPayload);
const lastSeenVersion = typeof existingMeta.lastSeenAppVersion === 'string'
? existingMeta.lastSeenAppVersion
: null;
const shouldOpen = shouldOpenChangelogForVersionChange(lastSeenVersion, currentVersion); 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 updatedAt = nowTimestampMs();
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
await db await db
.insert(userPreferences) .insert(userOnboarding)
.values({ .values({
userId: scope.ownerUserId, userId: scope.ownerUserId,
dataJson, lastSeenAppVersion: currentVersion,
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
updatedAt, updatedAt,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [userPreferences.userId], target: [userOnboarding.userId],
set: { set: {
dataJson, lastSeenAppVersion: currentVersion,
updatedAt, 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, sanitizePreferencesPatch,
type PreferenceNormalizationContext, type PreferenceNormalizationContext,
} from '@/lib/server/user/preferences-normalize'; } from '@/lib/server/user/preferences-normalize';
import {
deserializeUserPreferencesPayload,
extractUserPreferencesMeta,
withUserPreferencesMeta,
type UserPreferencesMeta,
} from '@/lib/server/user/preferences-payload';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -27,6 +21,22 @@ function serializePreferencesForDb(payload: Record<string, unknown>): Record<str
return JSON.stringify(payload); 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> { async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
const [runtimeConfig, providers] = await Promise.all([ const [runtimeConfig, providers] = await Promise.all([
getResolvedRuntimeConfig(), getResolvedRuntimeConfig(),
@ -49,11 +59,10 @@ async function loadPreferenceNormalizationContext(): Promise<PreferenceNormaliza
function parseStoredPreferences( function parseStoredPreferences(
value: unknown, value: unknown,
context: PreferenceNormalizationContext, context: PreferenceNormalizationContext,
): { patch: SyncedPreferencesPatch; migrated: boolean; meta: UserPreferencesMeta } { ): { patch: SyncedPreferencesPatch; migrated: boolean } {
const payload = deserializeUserPreferencesPayload(value); const payload = deserializePreferences(value);
const meta = extractUserPreferencesMeta(payload);
const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true }); const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true });
return { ...sanitized, meta }; return { ...sanitized, migrated: sanitized.migrated || '_meta' in payload };
} }
function normalizeClientUpdatedAtMs(value: unknown): number { function normalizeClientUpdatedAtMs(value: unknown): number {
@ -80,7 +89,6 @@ export async function GET(req: NextRequest) {
const row = rows[0]; const row = rows[0];
const stored = parseStoredPreferences(row?.dataJson, normalizationContext); const stored = parseStoredPreferences(row?.dataJson, normalizationContext);
const storedPatch = stored.patch; const storedPatch = stored.patch;
const storedPayload = withUserPreferencesMeta(storedPatch, stored.meta);
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0); const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
if (row && stored.migrated) { if (row && stored.migrated) {
@ -89,14 +97,14 @@ export async function GET(req: NextRequest) {
.insert(userPreferences) .insert(userPreferences)
.values({ .values({
userId: scope.ownerUserId, userId: scope.ownerUserId,
dataJson: serializePreferencesForDb(storedPayload), dataJson: serializePreferencesForDb(storedPatch),
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt, clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
updatedAt, updatedAt,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [userPreferences.userId], target: [userPreferences.userId],
set: { set: {
dataJson: serializePreferencesForDb(storedPayload), dataJson: serializePreferencesForDb(storedPatch),
updatedAt, updatedAt,
}, },
}); });
@ -161,8 +169,7 @@ export async function PUT(req: NextRequest) {
} }
const mergedPatch = { ...existingPatch, ...patch }; const mergedPatch = { ...existingPatch, ...patch };
const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta); const dataJson = serializePreferencesForDb(mergedPatch);
const dataJson = serializePreferencesForDb(payloadWithMeta);
const updatedAt = nowTimestampMs(); const updatedAt = nowTimestampMs();
await db await db

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { ReactNode, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/contexts/ThemeContext'; 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 ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RuntimeConfigProvider> <RuntimeConfigProvider>

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { updateAppConfig } from '@/lib/client/dexie'; import { useOnboardingState } from '@/hooks/useOnboardingState';
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui'; import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
interface PrivacyModalProps { interface PrivacyModalProps {
@ -47,6 +47,7 @@ function PrivacyModalBody({ origin }: { origin: string }) {
} }
export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) { export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) {
const { mutation } = useOnboardingState();
const [origin, setOrigin] = useState(''); const [origin, setOrigin] = useState('');
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
@ -62,7 +63,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
}, [isOpen]); }, [isOpen]);
const handleAccept = async () => { const handleAccept = async () => {
await updateAppConfig({ privacyAccepted: true }); await mutation.mutateAsync({ privacyAccepted: true });
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('openreader:privacyAccepted')); 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 { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { import {
isBuiltInTtsProviderId,
type TtsProviderType,
} from '@/lib/shared/tts-provider-catalog';
import {
defaultBaseUrlForProviderType,
defaultModelForProviderType,
resolveProviderDefaults, resolveProviderDefaults,
resolveEffectiveProviderType, resolveEffectiveProviderType,
resolveTtsProviderModelPolicy, resolveTtsProviderModelPolicy,
@ -38,8 +33,6 @@ import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel'; import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { AdminTasksPanel } from '@/components/admin/AdminTasksPanel'; import { AdminTasksPanel } from '@/components/admin/AdminTasksPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders'; 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 { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import { import {
SidebarDialog, SidebarDialog,
@ -219,7 +212,6 @@ export function SettingsModal({
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
const showAllProviderModels = runtimeConfig.showAllProviderModels; const showAllProviderModels = runtimeConfig.showAllProviderModels;
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab; const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
const isOpen = open; const isOpen = open;
const setIsOpen = onOpenChange; const setIsOpen = onOpenChange;
const [isChangelogOpen, setIsChangelogOpen] = useState(false); const [isChangelogOpen, setIsChangelogOpen] = useState(false);
@ -228,10 +220,8 @@ export function SettingsModal({
const { theme, setTheme, applyCustomColors } = useTheme(); const { theme, setTheme, applyCustomColors } = useTheme();
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors); const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
const [isCustomExpanded, setIsCustomExpanded] = useState(false); 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 { refreshDocuments } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
const [localProviderRef, setLocalProviderRef] = useState(providerRef); const [localProviderRef, setLocalProviderRef] = useState(providerRef);
const [localProviderType, setLocalProviderType] = useState<TtsProviderType>(providerType); const [localProviderType, setLocalProviderType] = useState<TtsProviderType>(providerType);
const [modelValue, setModelValue] = useState(ttsModel); const [modelValue, setModelValue] = useState(ttsModel);
@ -279,13 +269,12 @@ export function SettingsModal({
} = useMemo(() => resolveTtsSettingsViewModel({ } = useMemo(() => resolveTtsSettingsViewModel({
providerRef: localProviderRef, providerRef: localProviderRef,
providerType: localProviderType, providerType: localProviderType,
apiKey: localApiKey,
modelValue, modelValue,
customModelInput, customModelInput,
showAllProviderModels, showAllProviderModels,
sharedProviders, sharedProviders,
allowBuiltInProviders: !restrictUserApiKeys, allowBuiltInProviders: false,
}), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllProviderModels, sharedProviders, restrictUserApiKeys]); }), [localProviderRef, localProviderType, modelValue, customModelInput, showAllProviderModels, sharedProviders]);
const isSharedSelected = Boolean(selectedSharedProvider); const isSharedSelected = Boolean(selectedSharedProvider);
const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0]; 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 // focus refetch in ConfigContext, or async shared-provider loading) must not
// stomp their in-progress selection. On close, resetToCurrent re-syncs. // stomp their in-progress selection. On close, resetToCurrent re-syncs.
if (isOpen) return; if (isOpen) return;
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
setLocalProviderRef(providerRef); setLocalProviderRef(providerRef);
setLocalProviderType(providerType); setLocalProviderType(providerType);
setModelValue(ttsModel); setModelValue(ttsModel);
setLocalTTSInstructions(ttsInstructions); setLocalTTSInstructions(ttsInstructions);
}, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); }, [isOpen, providerRef, providerType, ttsModel, ttsInstructions]);
useEffect(() => { useEffect(() => {
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
@ -331,17 +318,9 @@ export function SettingsModal({
setModelValue(shared.defaultModel); setModelValue(shared.defaultModel);
} }
setLocalTTSInstructions(shared?.defaultInstructions ?? ''); setLocalTTSInstructions(shared?.defaultInstructions ?? '');
setLocalApiKey('');
setLocalBaseUrl('');
setCustomModelInput(''); setCustomModelInput('');
return; return;
} }
if (isBuiltInTtsProviderId(fallback.providerType)) {
setModelValue(defaultModelForProviderType(fallback.providerType));
setLocalBaseUrl(defaultBaseUrlForProviderType(fallback.providerType));
setCustomModelInput('');
}
}, [selectedProviderOption, ttsProviders, sharedProviders]); }, [selectedProviderOption, ttsProviders, sharedProviders]);
const handleRefresh = async () => { const handleRefresh = async () => {
@ -459,19 +438,9 @@ export function SettingsModal({
setShowDeleteAccountConfirm(false); 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(() => { const resetToCurrent = useCallback(() => {
setIsOpen(false); setIsOpen(false);
setIsChangelogOpen(false); setIsChangelogOpen(false);
setLocalApiKey(apiKey);
setLocalBaseUrl(baseUrl);
setLocalProviderRef(providerRef); setLocalProviderRef(providerRef);
setLocalProviderType(providerType); setLocalProviderType(providerType);
setModelValue(ttsModel); setModelValue(ttsModel);
@ -481,7 +450,7 @@ export function SettingsModal({
} else { } else {
setCustomModelInput(''); setCustomModelInput('');
} }
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]); }, [providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
const [systemIsDark, setSystemIsDark] = useState( const [systemIsDark, setSystemIsDark] = useState(
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
@ -546,13 +515,6 @@ export function SettingsModal({
model: modelValue, model: modelValue,
sharedProviders, 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 selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
const selectedModelVersion = selectedModel?.id?.includes(':') const selectedModelVersion = selectedModel?.id?.includes(':')
? selectedModel.id.slice(selectedModel.id.indexOf(':')) ? selectedModel.id.slice(selectedModel.id.indexOf(':'))
@ -642,53 +604,11 @@ export function SettingsModal({
setLocalProviderType(defaults.providerType); setLocalProviderType(defaults.providerType);
setModelValue(defaults.defaultModel); setModelValue(defaults.defaultModel);
setLocalTTSInstructions(defaults.defaultInstructions); 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(''); setCustomModelInput('');
}} }}
/> />
)} )}
</div> </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 && ( {isSharedSelected && (
<p className="text-xs text-soft"> <p className="text-xs text-soft">
This is a shared provider configured by an admin. API key and base URL are managed server-side. 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, providerRef: runtimeConfig.defaultTtsProvider,
sharedProviders, sharedProviders,
}); });
setLocalApiKey('');
setLocalBaseUrl('');
setLocalProviderRef(defaults.providerRef); setLocalProviderRef(defaults.providerRef);
setLocalProviderType(defaults.providerType); setLocalProviderType(defaults.providerType);
setModelValue(defaults.defaultModel); setModelValue(defaults.defaultModel);
@ -796,10 +714,6 @@ export function SettingsModal({
providerType: selectedProviderType, providerType: selectedProviderType,
sharedProviders, sharedProviders,
}); });
await updateConfig({
apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
});
await updateConfigKey('providerRef', selectedProviderRef); await updateConfigKey('providerRef', selectedProviderRef);
await updateConfigKey('providerType', selectedProviderType); await updateConfigKey('providerType', selectedProviderType);
const finalModel = showAllProviderModels const finalModel = showAllProviderModels
@ -807,16 +721,6 @@ export function SettingsModal({
: defaults.defaultModel; : defaults.defaultModel;
await updateConfigKey('ttsModel', finalModel); await updateConfigKey('ttsModel', finalModel);
await updateConfigKey('ttsInstructions', localTTSInstructions); 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); setIsOpen(false);
}} }}
> >

View file

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

View file

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

View file

@ -132,7 +132,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
if (status.kind === 'ready') { if (status.kind === 'ready') {
const primedUrl = await primeDocumentPreviewCache( const primedUrl = await primeDocumentPreviewCache(
doc.id, doc.id,
Number(doc.lastModified), status.previewVersion || Number(doc.lastModified),
previewKey, previewKey,
{ signal: controller.signal }, { signal: controller.signal },
).catch(() => null); ).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'; 'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { createContext, useContext, useMemo, type ReactNode } from 'react';
import { useLiveQuery } from 'dexie-react-hooks'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues } from '@/types/config';
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 { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; 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 { useAuthSession } from '@/hooks/useAuthSession';
import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
import { applyConfigUpdate } from '@/lib/client/config/updates'; import { applyConfigUpdate } from '@/lib/client/config/updates';
import { useSharedProviders } from '@/hooks/useSharedProviders'; 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'; export type { ViewType } from '@/types/config';
/** Configuration values for the application */
/** Interface defining the configuration context shape and functionality */
interface ConfigContextType { interface ConfigContextType {
apiKey: string;
baseUrl: string;
viewType: ViewType; viewType: ViewType;
voiceSpeed: number; voiceSpeed: number;
audioPlayerSpeed: number; audioPlayerSpeed: number;
@ -40,7 +31,6 @@ interface ConfigContextType {
ttsModel: string; ttsModel: string;
ttsInstructions: string; ttsInstructions: string;
savedVoices: SavedVoices; 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>; updateConfigKey: <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => Promise<void>;
isLoading: boolean; isLoading: boolean;
isDBReady: boolean; isDBReady: boolean;
@ -54,456 +44,82 @@ interface ConfigContextType {
const ConfigContext = createContext<ConfigContextType | undefined>(undefined); 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 }) { export function ConfigProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true); const { data: session, isPending: isSessionPending } = useAuthSession();
const [isDBReady, setIsDBReady] = useState(false); const sessionId = session?.user?.id ?? 'no-session';
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 { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders(); 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 adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider;
const { query, mutation } = useUserPreferences(sessionId, !isSessionPending);
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => { const config = useMemo<AppConfigValues>(() => ({
if (sessionKey === 'no-session') return; ...APP_CONFIG_DEFAULTS,
...(query.data?.preferences ?? {}),
}), [query.data?.preferences]);
const syncedPatch: SyncedPreferencesPatch = {}; const effectiveProvider = useMemo(() => resolveProviderDefaults({
for (const key of SYNCED_PREFERENCE_KEYS) { providerRef: config.providerRef,
if (!(key in patch)) continue; providerType: config.providerType,
const value = patch[key]; sharedProviders,
if (value === undefined) continue; fallbackProviderRef: adminDefaultProviderRef,
(syncedPatch as Record<SyncedPreferenceKey, unknown>)[key] = value; }), [adminDefaultProviderRef, config.providerRef, config.providerType, sharedProviders]);
}
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,
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]);
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 effectiveProviderRef = effectiveProvider.providerRef;
const providerType = effectiveProvider.providerType; const effectiveTtsModel = config.ttsModel || effectiveProvider.defaultModel;
const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel; const effectiveTtsInstructions = config.ttsInstructions || effectiveProvider.defaultInstructions;
const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions;
useEffect(() => { const updatePatch = async (patch: Partial<AppConfigValues>) => {
if (!isDBReady || !appConfig || sharedProvidersLoading) return; await mutation.mutateAsync(patch as SyncedPreferencesPatch);
// 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);
}
}; };
/**
* 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]) => { const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
try { const { syncPatch } = applyConfigUpdate({
setIsLoading(true); providerRef: effectiveProviderRef,
const { storagePatch, syncPatch } = applyConfigUpdate({ providerType: effectiveProvider.providerType,
providerRef: effectiveProviderRef, ttsModel: effectiveTtsModel,
providerType, savedVoices: config.savedVoices,
ttsModel: effectiveTtsModel, }, key, value);
savedVoices, await updatePatch(syncPatch);
}, 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);
}
}; };
const isLoading = isSessionPending || query.isPending || mutation.isPending || sharedProvidersLoading;
return ( return (
<ConfigContext.Provider value={{ <ConfigContext.Provider value={{
apiKey, viewType: config.viewType,
baseUrl, voiceSpeed: config.voiceSpeed,
viewType, audioPlayerSpeed: config.audioPlayerSpeed,
voiceSpeed, voice: config.voice,
audioPlayerSpeed, skipBlank: config.skipBlank,
voice, epubTheme: config.epubTheme,
skipBlank, segmentPreloadDepthPages: config.segmentPreloadDepthPages,
epubTheme, segmentPreloadSentenceLookahead: config.segmentPreloadSentenceLookahead,
segmentPreloadDepthPages, ttsSegmentMaxBlockLength: config.ttsSegmentMaxBlockLength,
segmentPreloadSentenceLookahead, headerMargin: config.headerMargin,
ttsSegmentMaxBlockLength, footerMargin: config.footerMargin,
headerMargin, leftMargin: config.leftMargin,
footerMargin, rightMargin: config.rightMargin,
leftMargin,
rightMargin,
providerRef: effectiveProviderRef, providerRef: effectiveProviderRef,
providerType, providerType: effectiveProvider.providerType,
ttsModel: effectiveTtsModel, ttsModel: effectiveTtsModel,
ttsInstructions: effectiveTtsInstructions, ttsInstructions: effectiveTtsInstructions,
savedVoices, savedVoices: config.savedVoices,
updateConfig,
updateConfigKey, updateConfigKey,
isLoading, isLoading,
isDBReady, isDBReady: !isSessionPending && !query.isPending,
pdfHighlightEnabled, pdfHighlightEnabled: config.pdfHighlightEnabled,
pdfWordHighlightEnabled, pdfWordHighlightEnabled: config.pdfWordHighlightEnabled,
epubHighlightEnabled, epubHighlightEnabled: config.epubHighlightEnabled,
epubWordHighlightEnabled, epubWordHighlightEnabled: config.epubWordHighlightEnabled,
htmlHighlightEnabled, htmlHighlightEnabled: config.htmlHighlightEnabled,
htmlWordHighlightEnabled, htmlWordHighlightEnabled: config.htmlWordHighlightEnabled,
}}> }}>
{children} {children}
</ConfigContext.Provider> </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() { export function useConfig() {
const context = useContext(ConfigContext); const context = useContext(ConfigContext);
if (context === undefined) { if (!context) throw new Error('useConfig must be used within a ConfigProvider');
throw new Error('useConfig must be used within a ConfigProvider');
}
return context; return context;
} }

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, ReactNode } from 'react'; 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 type { BaseDocument } from '@/types/documents';
import { import {
deleteDocuments as deleteServerDocuments, deleteDocuments as deleteServerDocuments,
@ -102,10 +102,31 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
return { pdfDocs, epubDocs, htmlDocs }; return { pdfDocs, epubDocs, htmlDocs };
}, [docs]); }, [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[]> => { const uploadDocuments = useCallback(async (files: File[]): Promise<BaseDocument[]> => {
if (files.length === 0) return []; if (files.length === 0) return [];
const stored = await uploadServerDocuments(files); const stored = await uploadMutation.mutateAsync(files);
await Promise.allSettled( await Promise.allSettled(
stored.map(async (document, index) => { stored.map(async (document, index) => {
const file = files[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; return stored;
}, [documentsQueryKey, queryClient]); }, [uploadMutation]);
const deleteDocument = useCallback(async (id: string) => { const deleteDocument = useCallback(async (id: string) => {
await deleteServerDocuments({ ids: [id] }); await deleteMutation.mutateAsync(id);
await evictCachedDocument(id); await evictCachedDocument(id);
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous = []) => }, [deleteMutation]);
previous.filter((document) => document.id !== id),
);
}, [documentsQueryKey, queryClient]);
return ( return (
<DocumentContext.Provider value={{ <DocumentContext.Provider value={{

View file

@ -2,16 +2,15 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal'; import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal';
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
import { PrivacyModal } from '@/components/PrivacyModal'; import { PrivacyModal } from '@/components/PrivacyModal';
import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthSession } from '@/hooks/useAuthSession';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; 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 { postChangelogVersionCheck } from '@/lib/client/api/user-state';
import { scheduleChangelogCheck } from '@/lib/client/changelog-check'; import { scheduleChangelogCheck } from '@/lib/client/changelog-check';
import { createCoalescedAsyncRunner, resolveNextOnboardingStep } from '@/lib/client/onboarding-flow'; 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 = { type OnboardingFlowContextValue = {
changelogOpenSignal: number; changelogOpenSignal: number;
@ -19,18 +18,14 @@ type OnboardingFlowContextValue = {
const OnboardingFlowContext = createContext<OnboardingFlowContextValue | null>(null); const OnboardingFlowContext = createContext<OnboardingFlowContextValue | null>(null);
type MigrationPromptState = {
shouldPrompt: boolean;
localCount: number;
missingCount: number;
};
const EMPTY_CLAIM_COUNTS: ClaimableCounts = { const EMPTY_CLAIM_COUNTS: ClaimableCounts = {
documents: 0, documents: 0,
audiobooks: 0, audiobooks: 0,
preferences: 0, preferences: 0,
progress: 0, progress: 0,
documentSettings: 0, documentSettings: 0,
folders: 0,
onboarding: 0,
}; };
function toClaimableCounts(value: unknown): ClaimableCounts { function toClaimableCounts(value: unknown): ClaimableCounts {
@ -41,6 +36,8 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
preferences: Number(rec.preferences ?? 0), preferences: Number(rec.preferences ?? 0),
progress: Number(rec.progress ?? 0), progress: Number(rec.progress ?? 0),
documentSettings: Number(rec.documentSettings ?? 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); 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 }) { export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
const { data: session, isPending: isSessionPending } = useAuthSession(); const { data: session, isPending: isSessionPending } = useAuthSession();
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined; const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined;
const userId = user?.id ?? null; const userId = user?.id ?? null;
const isAnonymous = Boolean(user?.isAnonymous); const isAnonymous = Boolean(user?.isAnonymous);
const claimCountsQuery = useQuery({
const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | 'migration' | null>(null); queryKey: queryKeys.claimCounts(userId ?? 'no-session'),
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>(EMPTY_CLAIM_COUNTS); queryFn: fetchClaimableCounts,
const [migrationCounts, setMigrationCounts] = useState<{ localCount: number; missingCount: number }>({ enabled: Boolean(userId && !isAnonymous),
localCount: 0,
missingCount: 0,
}); });
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 [changelogOpenSignal, setChangelogOpenSignal] = useState(0);
const pendingChangelogOpenRef = useRef(false); const pendingChangelogOpenRef = useRef(false);
@ -135,9 +83,17 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
); );
const runOnceFlow = useCallback(async () => { 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 privacyRequired = true;
const privacyAccepted = !privacyRequired || local.privacyAccepted; const privacyAccepted = !privacyRequired || Boolean(onboardingData.privacyAcceptedAtMs);
const isClaimEligible = Boolean( const isClaimEligible = Boolean(
userId userId
@ -149,26 +105,25 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
let claimHasData = false; let claimHasData = false;
if (isClaimEligible) { if (isClaimEligible) {
claimCounts = await fetchClaimableCounts(); claimCounts = (await refetchClaimCounts()).data ?? EMPTY_CLAIM_COUNTS;
const total = claimCounts.documents const total = claimCounts.documents
+ claimCounts.audiobooks + claimCounts.audiobooks
+ claimCounts.preferences + claimCounts.preferences
+ claimCounts.progress + claimCounts.progress
+ claimCounts.documentSettings; + claimCounts.documentSettings
+ claimCounts.folders
+ claimCounts.onboarding;
claimHasData = total > 0; claimHasData = total > 0;
if (!claimHasData && userId) { if (!claimHasData && userId) {
claimDismissedUsersRef.current.add(userId); claimDismissedUsersRef.current.add(userId);
} }
} }
const migrationState = await getMigrationPromptState(privacyAccepted);
const nextStep = resolveNextOnboardingStep({ const nextStep = resolveNextOnboardingStep({
privacyRequired, privacyRequired,
privacyAccepted, privacyAccepted,
claimEligible: isClaimEligible, claimEligible: isClaimEligible,
claimHasData, claimHasData,
migrationRequired: migrationState.shouldPrompt,
changelogPending: pendingChangelogOpenRef.current, changelogPending: pendingChangelogOpenRef.current,
}); });
@ -183,26 +138,13 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
return; return;
} }
if (nextStep === 'migration') {
setMigrationCounts({
localCount: migrationState.localCount,
missingCount: migrationState.missingCount,
});
setActiveBlockingModal('migration');
return;
}
setActiveBlockingModal(null); setActiveBlockingModal(null);
if (!local.firstVisitSettingsOpened) {
await setFirstVisit(true);
}
if (nextStep === 'changelog') { if (nextStep === 'changelog') {
pendingChangelogOpenRef.current = false; pendingChangelogOpenRef.current = false;
setChangelogOpenSignal((value) => value + 1); setChangelogOpenSignal((value) => value + 1);
} }
}, [isAnonymous, userId]); }, [isAnonymous, onboardingQuery.data, refetchClaimCounts, userId]);
runOnceFlowRef.current = runOnceFlow; runOnceFlowRef.current = runOnceFlow;
@ -214,11 +156,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
void runFlow(); void runFlow();
}, [runFlow, userId]); }, [runFlow, userId]);
const handleMigrationComplete = useCallback(() => {
setActiveBlockingModal(null);
void runFlow();
}, [runFlow]);
const handlePrivacyAccepted = useCallback(() => { const handlePrivacyAccepted = useCallback(() => {
setActiveBlockingModal(null); setActiveBlockingModal(null);
void runFlow(); void runFlow();
@ -226,7 +163,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
void runFlow(); void runFlow();
}, [isAnonymous, runFlow, userId]); }, [isAnonymous, onboardingQuery.data, runFlow, userId]);
useEffect(() => { useEffect(() => {
const onPrivacyAccepted = () => { const onPrivacyAccepted = () => {
@ -273,12 +210,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
onDismiss={handleClaimComplete} onDismiss={handleClaimComplete}
onClaimed={handleClaimComplete} onClaimed={handleClaimComplete}
/> />
<DexieMigrationModal
isOpen={activeBlockingModal === 'migration'}
localCount={migrationCounts.localCount}
missingCount={migrationCounts.missingCount}
onComplete={handleMigrationComplete}
/>
</OnboardingFlowContext.Provider> </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 * 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. * built-in defaults during SSR.
*/ */
export function readRuntimeConfigSync(): RuntimeConfig { export function readRuntimeConfigSync(): RuntimeConfig {

View file

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

View file

@ -35,8 +35,6 @@ export function filterNonEmptySpineTextEntries<T extends { text: string }>(entri
type UseEpubAudiobookParams = { type UseEpubAudiobookParams = {
bookRef: RefObject<Book | null>; bookRef: RefObject<Book | null>;
tocRef: RefObject<NavItem[]>; tocRef: RefObject<NavItem[]>;
apiKey: string;
baseUrl: string;
providerRef: string; providerRef: string;
}; };
@ -61,8 +59,6 @@ type UseEpubAudiobookResult = {
export function useEPUBAudiobook({ export function useEPUBAudiobook({
bookRef, bookRef,
tocRef, tocRef,
apiKey,
baseUrl,
providerRef, providerRef,
}: UseEpubAudiobookParams): UseEpubAudiobookResult { }: UseEpubAudiobookParams): UseEpubAudiobookResult {
const loadSpineSection = useCallback(async (href: string) => { const loadSpineSection = useCallback(async (href: string) => {
@ -131,8 +127,6 @@ export function useEPUBAudiobook({
try { try {
return await runAudiobookGeneration({ return await runAudiobookGeneration({
adapter: audiobookAdapter, adapter: audiobookAdapter,
apiKey,
baseUrl,
defaultProvider: providerRef, defaultProvider: providerRef,
onProgress, onProgress,
signal, signal,
@ -145,7 +139,7 @@ export function useEPUBAudiobook({
console.error('Error creating audiobook:', error); console.error('Error creating audiobook:', error);
throw error; throw error;
} }
}, [audiobookAdapter, apiKey, baseUrl, providerRef]); }, [audiobookAdapter, providerRef]);
const regenerateChapter = useCallback(async ( const regenerateChapter = useCallback(async (
chapterIndex: number, chapterIndex: number,
@ -161,8 +155,6 @@ export function useEPUBAudiobook({
bookId, bookId,
format, format,
signal, signal,
apiKey,
baseUrl,
defaultProvider: providerRef, defaultProvider: providerRef,
settings, settings,
}); });
@ -173,7 +165,7 @@ export function useEPUBAudiobook({
console.error('Error regenerating chapter:', error); console.error('Error regenerating chapter:', error);
throw error; throw error;
} }
}, [audiobookAdapter, apiKey, baseUrl, providerRef]); }, [audiobookAdapter, providerRef]);
return { return {
createFullAudioBook, 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 { useCallback, type MutableRefObject, type RefObject } from 'react';
import type { Book, Rendition } from 'epubjs'; import type { Book, Rendition } from 'epubjs';
import { setLastDocumentLocation } from '@/lib/client/dexie'; import { useDocumentProgress } from '@/hooks/useDocumentProgress';
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
type EpubLocation = string | number; import {
isDirectionalEpubLocation,
export function isDirectionalEpubLocation(location: EpubLocation): location is 'next' | 'prev' { shouldNavigateToDifferentCfi,
return location === 'next' || location === 'prev'; shouldPersistEpubLocation,
} type EpubLocation,
} from '@/lib/client/epub/location-controller';
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;
}
type UseEpubLocationControllerParams = { type UseEpubLocationControllerParams = {
documentId?: string; documentId?: string;
@ -54,6 +35,7 @@ export function useEPUBLocationController({
renditionRef, renditionRef,
locationRef, locationRef,
}: UseEpubLocationControllerParams): (location: EpubLocation) => void { }: UseEpubLocationControllerParams): (location: EpubLocation) => void {
const { schedule: scheduleProgress } = useDocumentProgress(documentId);
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => { const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
const book = bookRef.current; const book = bookRef.current;
const rendition = renditionRef.current; const rendition = renditionRef.current;
@ -133,10 +115,9 @@ export function useEPUBLocationController({
return; 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)) { if (shouldPersistEpubLocation(documentId, locationRef.current)) {
setLastDocumentLocation(documentId, location.toString()); scheduleProgress({
scheduleDocumentProgressSync({
documentId, documentId,
readerType: 'epub', readerType: 'epub',
location: location.toString(), location: location.toString(),
@ -160,8 +141,11 @@ export function useEPUBLocationController({
safeRenditionNavigate, safeRenditionNavigate,
setIsEpub, setIsEpub,
shouldPauseRef, shouldPauseRef,
scheduleProgress,
skipToLocation, skipToLocation,
]); ]);
return handleLocationChanged; 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'; '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 { mergeDocumentSettings } from '@/lib/shared/document-settings';
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings'; import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
import { useDocumentSettings } from '@/hooks/useDocumentSettings';
export function useDocumentLanguage(documentId: string | undefined): { export function useDocumentLanguage(documentId: string | undefined): {
language: string; language: string;
updateLanguage: (language: string) => Promise<void>; updateLanguage: (language: string) => Promise<void>;
} { } {
const [settings, setSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS); const { query, mutation } = useDocumentSettings(documentId);
const settings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, query.data?.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 updateLanguage = useCallback(async (language: string): Promise<void> => { const updateLanguage = useCallback(async (language: string): Promise<void> => {
if (!documentId) return; if (!documentId) return;
let next = DEFAULT_DOCUMENT_SETTINGS; const next: DocumentSettings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
setSettings((prev) => { ...settings,
next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, { schemaVersion: 1,
...prev, language,
schemaVersion: 1,
language,
});
return next;
}); });
try { try {
const response = await putDocumentSettings(documentId, next); await mutation.mutateAsync(next);
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
} catch (error) { } catch (error) {
console.warn('Failed to persist document language:', error); console.warn('Failed to persist document language:', error);
} }
}, [documentId]); }, [documentId, mutation, settings]);
return { return {
language: settings.language ?? 'auto', 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> { export async function getDocumentMetadata(id: string, options?: { signal?: AbortSignal }): Promise<BaseDocument | null> {
const docs = await listDocuments({ ids: [id], signal: options?.signal }); 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 { 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> { 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 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 }); const res = await fetch(fallbackUrl, { signal: options?.signal });
if (!res.ok) { if (!res.ok) {
const contentType = res.headers.get('content-type') || ''; 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})`); throw new Error(`Failed to download document (status ${res.status})`);
} }
return res.arrayBuffer(); return res;
}; };
try { try {
@ -467,7 +475,7 @@ export async function downloadDocumentContent(id: string, options?: { signal?: A
} }
throw new Error(`Failed to download document (status ${directRes.status})`); throw new Error(`Failed to download document (status ${directRes.status})`);
} }
return directRes.arrayBuffer(); return directRes;
} catch (error) { } catch (error) {
if (options?.signal?.aborted) throw error; if (options?.signal?.aborted) throw error;
return fetchFallback(); return fetchFallback();
@ -508,6 +516,7 @@ export type DocumentPreviewReady = {
fallbackUrl: string; fallbackUrl: string;
presignUrl: string; presignUrl: string;
directUrl?: string; directUrl?: string;
previewVersion: string;
}; };
export type DocumentPreviewStatus = DocumentPreviewPending | DocumentPreviewReady; export type DocumentPreviewStatus = DocumentPreviewPending | DocumentPreviewReady;
@ -556,12 +565,14 @@ export async function getDocumentPreviewStatus(
fallbackUrl?: string; fallbackUrl?: string;
presignUrl?: string; presignUrl?: string;
directUrl?: string; directUrl?: string;
previewVersion?: string;
} | null; } | null;
return { return {
kind: 'ready', kind: 'ready',
fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id), fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id),
presignUrl: data?.presignUrl || documentPreviewPresignUrl(id), presignUrl: data?.presignUrl || documentPreviewPresignUrl(id),
directUrl: data?.directUrl, directUrl: data?.directUrl,
previewVersion: data?.previewVersion || '',
}; };
} }
@ -606,4 +617,3 @@ export async function importUrl(
return (await res.json()) as { title: string; content: string }; 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; 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( export async function getDocumentProgress(
documentId: string, documentId: string,
options?: { signal?: AbortSignal }, options?: { signal?: AbortSignal },
@ -216,56 +120,3 @@ export async function putDocumentProgress(payload: {
const data = (await res.json()) as ProgressResponse; const data = (await res.json()) as ProgressResponse;
return data.progress ?? null; 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 { interface RunAudiobookGenerationOptions {
adapter: AudiobookSourceAdapter; adapter: AudiobookSourceAdapter;
apiKey: string;
baseUrl: string;
defaultProvider: string; defaultProvider: string;
onProgress: (progress: number) => void; onProgress: (progress: number) => void;
signal?: AbortSignal; signal?: AbortSignal;
@ -47,8 +45,6 @@ interface RegenerateAudiobookChapterOptions {
bookId: string; bookId: string;
format: TTSAudiobookFormat; format: TTSAudiobookFormat;
signal: AbortSignal; signal: AbortSignal;
apiKey: string;
baseUrl: string;
defaultProvider: string; defaultProvider: string;
settings?: AudiobookGenerationSettings; settings?: AudiobookGenerationSettings;
retryOptions?: TTSRetryOptions; retryOptions?: TTSRetryOptions;
@ -71,14 +67,10 @@ function resolveAudiobookRequestSettings(
} }
function buildAudiobookRequestHeaders( function buildAudiobookRequestHeaders(
apiKey: string,
baseUrl: string,
effectiveProvider: string, effectiveProvider: string,
): TTSRequestHeaders { ): TTSRequestHeaders {
return { return {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
'x-tts-provider': effectiveProvider, 'x-tts-provider': effectiveProvider,
}; };
} }
@ -95,8 +87,6 @@ function createAudiobookAbortError(): Error {
export async function runAudiobookGeneration({ export async function runAudiobookGeneration({
adapter, adapter,
apiKey,
baseUrl,
defaultProvider, defaultProvider,
onProgress, onProgress,
signal, signal,
@ -120,7 +110,7 @@ export async function runAudiobookGeneration({
} }
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format); const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef); const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef);
let processedLength = 0; let processedLength = 0;
let bookId = providedBookId; let bookId = providedBookId;
@ -221,8 +211,6 @@ export async function regenerateAudiobookChapter({
bookId, bookId,
format, format,
signal, signal,
apiKey,
baseUrl,
defaultProvider, defaultProvider,
settings, settings,
retryOptions = { retryOptions = {
@ -238,7 +226,7 @@ export async function regenerateAudiobookChapter({
} }
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format); const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef); const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef);
return withRetry( return withRetry(
async () => { 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 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 = { function stableKey(meta: BaseDocument): string {
get: (meta: BaseDocument) => Promise<PDFDocument | EPUBDocument | HTMLDocument | null>; return documentBlobCacheKey(meta.id, meta.contentVersion || meta.id);
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;
};
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, meta: BaseDocument,
backend: DocumentCacheBackend,
options?: { signal?: AbortSignal }, options?: { signal?: AbortSignal },
): Promise<PDFDocument | EPUBDocument | HTMLDocument> { ): Promise<PDFDocument | EPUBDocument | HTMLDocument> {
const cached = await backend.get(meta); if (meta.type !== 'pdf' && meta.type !== 'epub' && meta.type !== 'html') {
if (cached) return cached; throw new Error(`Unsupported cached document type: ${meta.type}`);
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;
} }
const response = await getCachedBlob(stableKey(meta), () => fetchDocumentContentResponse(meta.id, options));
if (meta.type === 'epub') { return readDocument(meta, response);
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()]);
} }
export async function cacheStoredDocumentFromBytes(stored: BaseDocument, bytes: ArrayBuffer): Promise<void> { export async function cacheStoredDocumentFromBytes(stored: BaseDocument, bytes: ArrayBuffer): Promise<void> {
if (stored.type === 'pdf') { const type = stored.type === 'pdf'
await putCachedPdf(stored, bytes); ? 'application/pdf'
return; : stored.type === 'epub'
} ? 'application/epub+zip'
if (stored.type === 'epub') { : 'text/plain; charset=utf-8';
await putCachedEpub(stored, bytes); await putCachedBlob(stableKey(stored), new Response(bytes, { status: 200, headers: { 'Content-Type': type } }));
return;
}
if (stored.type === 'html') {
const decoded = new TextDecoder().decode(new Uint8Array(bytes));
await putCachedHtml(stored, decoded);
}
} }
export async function ensureCachedDocument(meta: BaseDocument, options?: { signal?: AbortSignal }): Promise<PDFDocument | EPUBDocument | HTMLDocument> { export async function evictCachedDocument(id: string): Promise<void> {
return ensureCachedDocumentCore( await evictCachedBlobPrefix(`/openreader-cache/documents/${encodeURIComponent(id)}/`);
meta, }
{
get: async (m) => { export async function clearDocumentCache(): Promise<void> {
const { getPdfDocument, getEpubDocument, getHtmlDocument } = await import('@/lib/client/dexie'); await clearBlobCache();
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,
);
} }

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 { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/client/api/documents';
import { clearBlobCache, getCachedBlob, previewBlobCacheKey } from '@/lib/client/cache/blob-cache';
const inMemoryPreviewUrlCache = new Map<string, string>(); const inMemoryPreviewUrlCache = new Map<string, string>();
const inFlightPreviewPrime = new Map<string, Promise<string | null>>(); 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 { function revokeIfBlobUrl(url: string | null | undefined): void {
if (!url) return; if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
if (!url.startsWith('blob:')) return;
try {
URL.revokeObjectURL(url);
} catch {
// ignore
}
} }
export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null { 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 { export function setInMemoryDocumentPreviewUrl(cacheKey: string, url: string): void {
const prev = inMemoryPreviewUrlCache.get(cacheKey); const prev = inMemoryPreviewUrlCache.get(cacheKey);
if (prev && prev !== url) { if (prev && prev !== url) revokeIfBlobUrl(prev);
revokeIfBlobUrl(prev);
}
inMemoryPreviewUrlCache.set(cacheKey, url); inMemoryPreviewUrlCache.set(cacheKey, url);
} }
export function clearInMemoryDocumentPreviewCache(): void { export function clearInMemoryDocumentPreviewCache(): void {
for (const value of inMemoryPreviewUrlCache.values()) { for (const value of inMemoryPreviewUrlCache.values()) revokeIfBlobUrl(value);
revokeIfBlobUrl(value);
}
inMemoryPreviewUrlCache.clear(); 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( export async function getPersistedDocumentPreviewUrl(
docId: string, docId: string,
lastModified: number, previewVersion: string | number,
cacheKey: string, cacheKey: string,
): Promise<string | null> { ): Promise<string | null> {
const cachedUrl = getInMemoryDocumentPreviewUrl(cacheKey); return primeDocumentPreviewCache(docId, previewVersion, 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);
}
}
} }
export async function primeDocumentPreviewCache( export async function primeDocumentPreviewCache(
docId: string, docId: string,
lastModified: number, previewVersion: string | number,
cacheKey: string, cacheKey: string,
options?: { signal?: AbortSignal }, options?: { signal?: AbortSignal },
): Promise<string | null> { ): Promise<string | null> {
const primeKey = `${cacheKey}:${Number(lastModified)}`; const memory = getInMemoryDocumentPreviewUrl(cacheKey);
const existingPrime = inFlightPreviewPrime.get(primeKey); if (memory) return memory;
if (existingPrime) { const primeKey = `${docId}:${previewVersion}`;
return existingPrime; const existing = inFlightPreviewPrime.get(primeKey);
}
const promise = (async (): Promise<string | null> => {
const existing = await getPersistedDocumentPreviewUrl(docId, lastModified, cacheKey);
if (existing) return existing; if (existing) return existing;
const promise = (async () => {
const fetchOptions = { const response = await getCachedBlob(
signal: options?.signal, previewBlobCacheKey(docId, previewVersion),
cache: 'no-store' as const, () => fetchPreviewSource(docId, options?.signal),
}; ).catch(() => null);
if (!response?.ok) return null;
// Prefer presign path for priming so healthy direct object access avoids proxy load. const blob = await response.blob();
let res = await fetch(documentPreviewPresignUrl(docId), fetchOptions).catch(() => null); if (blob.size === 0) return null;
if (!res || !res.ok) { const url = URL.createObjectURL(blob);
res = await fetch(documentPreviewFallbackUrl(docId), fetchOptions).catch(() => null); setInMemoryDocumentPreviewUrl(cacheKey, url);
} return url;
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 }));
setInMemoryDocumentPreviewUrl(cacheKey, url);
return url;
})(); })();
inFlightPreviewPrime.set(primeKey, promise); inFlightPreviewPrime.set(primeKey, promise);
try { try {
return await promise; return await promise;
} finally { } finally {
if (inFlightPreviewPrime.get(primeKey) === promise) { inFlightPreviewPrime.delete(primeKey);
inFlightPreviewPrime.delete(primeKey);
}
} }
} }
export async function clearAllDocumentPreviewCaches(): Promise<void> { export async function clearAllDocumentPreviewCaches(): Promise<void> {
clearInMemoryDocumentPreviewCache(); 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 = { export type OnboardingStepSnapshot = {
privacyRequired: boolean; privacyRequired: boolean;
privacyAccepted: boolean; privacyAccepted: boolean;
claimEligible: boolean; claimEligible: boolean;
claimHasData: boolean; claimHasData: boolean;
migrationRequired: boolean;
changelogPending: boolean; changelogPending: boolean;
}; };
@ -18,10 +17,6 @@ export function resolveNextOnboardingStep(snapshot: OnboardingStepSnapshot): Onb
return 'claim'; return 'claim';
} }
if (snapshot.migrationRequired) {
return 'migration';
}
if (snapshot.changelogPending) { if (snapshot.changelogPending) {
return 'changelog'; 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 { export interface ResolvedTtsCredentials {
/** Provider id passed downstream to TTS generation (one of the 4 built-in IDs). */ /** Provider id passed downstream to TTS generation (one of the 4 built-in IDs). */
provider: string; 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; apiKey: string;
/** Base URL, or undefined to fall through to provider defaults. */ /** Base URL, or undefined to fall through to provider defaults. */
baseUrl: string | undefined; baseUrl: string | undefined;
@ -21,62 +21,26 @@ export interface ResolvedTtsCredentials {
/** /**
* Resolve TTS credentials for an incoming request. * Resolve TTS credentials for an incoming request.
* *
* 1. If `restrictUserApiKeys` is enabled, only admin shared providers are * Only admin-managed shared providers can supply credentials. Built-in
* used. Built-in provider ids and user-supplied key/base headers are * provider ids select the preferred enabled shared provider of that type.
* 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.
* *
* Returns `null` when the request references a slug that exists but is * Returns `null` when the request references a slug that exists but is
* disabled callers should reject with a 4xx. * disabled callers should reject with a 4xx.
*/ */
export async function resolveTtsCredentials(opts: { export async function resolveTtsCredentials(opts: {
providerHeader: string | null; providerHeader: string | null;
apiKeyHeader: string | null;
baseUrlHeader: string | null;
fallbackProvider?: string; fallbackProvider?: string;
restrictUserApiKeys?: boolean;
}): Promise<ResolvedTtsCredentials | { error: 'provider_disabled' | 'provider_unknown' | 'no_shared_provider_configured'; slug: string }> { }): Promise<ResolvedTtsCredentials | { error: 'provider_disabled' | 'provider_unknown' | 'no_shared_provider_configured'; slug: string }> {
const requestedProvider = opts.providerHeader || opts.fallbackProvider || 'openai'; const requestedProvider = opts.providerHeader || opts.fallbackProvider || 'openai';
if (opts.restrictUserApiKeys) { const admin = isBuiltInProviderId(requestedProvider)
const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider); ? await resolvePreferredEnabledAdminProvider({
const fallback = opts.fallbackProvider || ''; requestedSlug: null,
const selected = await resolvePreferredEnabledAdminProvider({ runtimeDefaultSlug: opts.fallbackProvider || '',
requestedSlug: requestedIsBuiltIn ? null : requestedProvider, })
runtimeDefaultSlug: fallback, : await getEnabledAdminProviderBySlug(requestedProvider);
});
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);
if (!admin) { if (!admin) {
return { error: 'provider_unknown', slug: requestedProvider }; return { error: 'no_shared_provider_configured', slug: requestedProvider };
} }
const apiKey = await decryptedKeyFor(admin); const apiKey = await decryptedKeyFor(admin);

View file

@ -8,6 +8,8 @@ import {
ttsSegmentVariants, ttsSegmentVariants,
userPreferences, userPreferences,
userDocumentProgress, userDocumentProgress,
userFolders,
userOnboarding,
} from '@openreader/database/schema'; } from '@openreader/database/schema';
import { eq, and, inArray } from 'drizzle-orm'; import { eq, and, inArray } from 'drizzle-orm';
import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy'; import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy';
@ -115,7 +117,7 @@ export async function claimAnonymousData(
options?: { cleanupLegacySources?: boolean }, options?: { cleanupLegacySources?: boolean },
) { ) {
if (!userId) { 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([ const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([
@ -129,13 +131,16 @@ export async function claimAnonymousData(
.where(eq(audiobooks.userId, unclaimedUserId)) as Promise<Array<{ id: string }>>, .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 }), transferUserDocuments(unclaimedUserId, userId, { namespace, transferTts: true }),
transferUserAudiobooks(unclaimedUserId, userId, namespace), transferUserAudiobooks(unclaimedUserId, userId, namespace),
transferUserPreferences(unclaimedUserId, userId), transferUserPreferences(unclaimedUserId, userId),
transferUserProgress(unclaimedUserId, userId), transferUserProgress(unclaimedUserId, userId),
transferUserDocumentSettings(unclaimedUserId, userId), transferUserDocumentSettings(unclaimedUserId, userId),
transferUserOnboarding(unclaimedUserId, userId),
]); ]);
await db.delete(userFolders).where(eq(userFolders.userId, unclaimedUserId));
if ( if (
options?.cleanupLegacySources !== false options?.cleanupLegacySources !== false
@ -168,9 +173,30 @@ export async function claimAnonymousData(
preferences: preferencesClaimed, preferences: preferencesClaimed,
progress: progressClaimed, progress: progressClaimed,
documentSettings: documentSettingsClaimed, 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. * Transfer documents from one userId to another.
* *

View file

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

View file

@ -125,6 +125,11 @@ export function sanitizePreferencesPatch(
case 'savedVoices': case 'savedVoices':
out[key] = sanitizeSavedVoices(value); out[key] = sanitizeSavedVoices(value);
break; break;
case 'documentListState':
if (isRecord(value)) {
out[key] = { ...value, folders: [] } as unknown as SyncedPreferencesPatch[typeof key];
}
break;
default: default:
break; 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 { export interface AppConfigValues {
apiKey: string;
baseUrl: string;
viewType: ViewType; viewType: ViewType;
voiceSpeed: number; voiceSpeed: number;
audioPlayerSpeed: number; audioPlayerSpeed: number;
@ -55,10 +53,7 @@ export interface AppConfigValues {
epubWordHighlightEnabled: boolean; epubWordHighlightEnabled: boolean;
htmlHighlightEnabled: boolean; htmlHighlightEnabled: boolean;
htmlWordHighlightEnabled: boolean; htmlWordHighlightEnabled: boolean;
firstVisit: boolean;
documentListState: DocumentListState; 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 // provider id (the old 'custom-openai') into every user's config, since that
// value isn't actually selectable in shared-provider mode. // value isn't actually selectable in shared-provider mode.
return { return {
apiKey: '',
baseUrl: '',
viewType: 'single', viewType: 'single',
voiceSpeed: 1, voiceSpeed: 1,
audioPlayerSpeed: 1, audioPlayerSpeed: 1,
@ -101,7 +94,6 @@ export function getAppConfigDefaults(): AppConfigValues {
epubWordHighlightEnabled: true, epubWordHighlightEnabled: true,
htmlHighlightEnabled: true, htmlHighlightEnabled: true,
htmlWordHighlightEnabled: true, htmlWordHighlightEnabled: true,
firstVisit: false,
documentListState: { documentListState: {
sortBy: 'name', sortBy: 'name',
sortDirection: 'asc', sortDirection: 'asc',
@ -110,8 +102,6 @@ export function getAppConfigDefaults(): AppConfigValues {
showHint: true, showHint: true,
viewMode: 'grid', viewMode: 'grid',
}, },
privacyAccepted: false,
documentsMigrationPrompted: false,
}; };
} }

View file

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

View file

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

View file

@ -84,7 +84,7 @@ test.describe('Document folders and hint persistence', () => {
// Hint should disappear // Hint should disappear
await expect(hint).toHaveCount(0); 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); await waitForDocumentListHintPersist(page, false);
// Reload and ensure it remains dismissed // 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 }); 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) { export async function waitForDocumentListHintPersist(page: Page, expected: boolean) {
await page.waitForFunction(async (exp) => { await page.waitForFunction(async (exp) => {
try { try {
const openDb = () => new Promise<IDBDatabase>((resolve, reject) => { const response = await fetch('/api/user/state/preferences', { cache: 'no-store' });
const req = indexedDB.open('openreader-db'); if (!response.ok) return false;
req.onsuccess = () => resolve(req.result); const item = await response.json() as { preferences?: { documentListState?: unknown } };
req.onerror = () => reject(req.error); const state = item.preferences?.documentListState;
});
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;
if (!state || typeof state !== 'object') return false; if (!state || typeof state !== 'object') return false;
const showHint = (state as { showHint?: unknown }).showHint; const showHint = (state as { showHint?: unknown }).showHint;
return typeof showHint === 'boolean' && showHint === exp; 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, isDirectionalEpubLocation,
shouldNavigateToDifferentCfi, shouldNavigateToDifferentCfi,
shouldPersistEpubLocation, shouldPersistEpubLocation,
} from '../../src/hooks/epub/useEPUBLocationController'; } from '../../src/lib/client/epub/location-controller';
describe('EPUB location controller helpers', () => { describe('EPUB location controller helpers', () => {
test('detects directional locations', () => { test('detects directional locations', () => {

View file

@ -9,7 +9,6 @@ describe('onboarding flow resolver', () => {
privacyAccepted: false, privacyAccepted: false,
claimEligible: true, claimEligible: true,
claimHasData: true, claimHasData: true,
migrationRequired: true,
changelogPending: true, changelogPending: true,
}); });
@ -22,33 +21,18 @@ describe('onboarding flow resolver', () => {
privacyAccepted: true, privacyAccepted: true,
claimEligible: true, claimEligible: true,
claimHasData: true, claimHasData: true,
migrationRequired: true,
changelogPending: true, changelogPending: true,
}); });
expect(step).toBe('claim'); 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', () => { test('resolves changelog when prior steps are clear', () => {
const step = resolveNextOnboardingStep({ const step = resolveNextOnboardingStep({
privacyRequired: true, privacyRequired: true,
privacyAccepted: true, privacyAccepted: true,
claimEligible: true, claimEligible: true,
claimHasData: false, claimHasData: false,
migrationRequired: false,
changelogPending: true, changelogPending: true,
}); });
@ -61,7 +45,6 @@ describe('onboarding flow resolver', () => {
privacyAccepted: true, privacyAccepted: true,
claimEligible: true, claimEligible: true,
claimHasData: false, claimHasData: false,
migrationRequired: false,
changelogPending: false, changelogPending: false,
}); });
const minimalStep = resolveNextOnboardingStep({ const minimalStep = resolveNextOnboardingStep({
@ -69,7 +52,6 @@ describe('onboarding flow resolver', () => {
privacyAccepted: false, privacyAccepted: false,
claimEligible: false, claimEligible: false,
claimHasData: false, claimHasData: false,
migrationRequired: false,
changelogPending: 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, size INTEGER NOT NULL,
last_modified INTEGER NOT NULL, last_modified INTEGER NOT NULL,
file_path TEXT NOT NULL, file_path TEXT NOT NULL,
folder_id TEXT,
recently_opened_at INTEGER,
parse_state TEXT, parse_state TEXT,
parsed_json_key TEXT, parsed_json_key TEXT,
created_at INTEGER, created_at INTEGER,
@ -149,6 +151,8 @@ describe('transferUserDocuments', () => {
size INTEGER NOT NULL, size INTEGER NOT NULL,
last_modified INTEGER NOT NULL, last_modified INTEGER NOT NULL,
file_path TEXT NOT NULL, file_path TEXT NOT NULL,
folder_id TEXT,
recently_opened_at INTEGER,
parse_state TEXT, parse_state TEXT,
parsed_json_key TEXT, parsed_json_key TEXT,
created_at INTEGER, created_at INTEGER,

View file

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