Compare commits

..

No commits in common. "refactor/data-storage" and "main" have entirely different histories.

93 changed files with 3535 additions and 5816 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.
- TTS credentials are resolved only from admin-managed shared providers and are never accepted from client request headers. - The user's per-request `x-openai-key` / `x-openai-base-url` headers are ignored for shared slugs.
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
- Browser blob performance cache: Cache Storage (evictable; never authoritative) - Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB)
- 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,6 +29,7 @@ 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,6 +54,8 @@
"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",
@ -100,8 +102,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",
"openapi-typescript": "^7.10.1",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"openapi-typescript": "^7.10.1",
"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 = { readonly deps: ComputeWorkerRouteDeps = {
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

@ -1,26 +0,0 @@
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,13 +85,6 @@
"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

@ -1,46 +0,0 @@
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,13 +85,6 @@
"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,13 +7,11 @@ 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,18 +4,6 @@ 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' }),
@ -24,19 +12,11 @@ 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', {
@ -109,14 +89,6 @@ 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,18 +4,6 @@ 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' }),
@ -24,19 +12,11 @@ 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', {
@ -109,14 +89,6 @@ 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,6 +59,12 @@ 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
@ -2600,6 +2606,15 @@ 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==}
@ -7074,6 +7089,13 @@ 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 } from "next/navigation"; import { useParams, useRouter } 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,6 +14,7 @@ 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';
@ -25,6 +26,7 @@ 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 {
@ -60,13 +62,19 @@ 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 = routeDocumentId; const resolved = await resolveDocumentId(routeDocumentId);
if (resolved !== routeDocumentId) {
didRedirect = true;
router.replace(`/epub/${resolved}`);
return;
}
if (loadedDocIdRef.current === resolved) { if (loadedDocIdRef.current === resolved) {
return; return;
@ -87,11 +95,11 @@ export default function EPUBPage() {
if (startedLoad) { if (startedLoad) {
inFlightDocIdRef.current = null; inFlightDocIdRef.current = null;
} }
if (startedLoad) { if (!didRedirect && startedLoad) {
setIsLoading(false); setIsLoading(false);
} }
} }
}, [routeDocumentId, setCurrentDocument, stop]); }, [routeDocumentId, router, setCurrentDocument, stop]);
useEffect(() => { useEffect(() => {
if (!isLoading) return; if (!isLoading) return;

View file

@ -115,6 +115,8 @@ 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,
@ -193,7 +195,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
}, [resetHighlightState, setCurrDocPages, stop]); }, [resetHighlightState, setCurrDocPages, stop]);
/** /**
* Sets the current document based on its ID using server metadata and the browser blob cache. * Sets the current document based on its ID by fetching from IndexedDB
* @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
*/ */
@ -476,6 +478,8 @@ 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 } from "next/navigation"; import { useParams, useRouter } 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,6 +9,7 @@ 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';
@ -25,6 +26,7 @@ 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 {
@ -61,13 +63,19 @@ 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 = id as string; const resolved = await resolveDocumentId(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;
@ -88,11 +96,11 @@ export default function HTMLPage() {
if (startedLoad) { if (startedLoad) {
inFlightDocIdRef.current = null; inFlightDocIdRef.current = null;
} }
if (startedLoad) { if (!didRedirect && startedLoad) {
setIsLoading(false); setIsLoading(false);
} }
} }
}, [isLoading, id, setCurrentDocument, stop]); }, [isLoading, id, router, setCurrentDocument, stop]);
useEffect(() => { useEffect(() => {
if (!isLoading) return; if (!isLoading) return;

View file

@ -66,6 +66,8 @@ 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();
@ -171,6 +173,8 @@ 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,
@ -185,7 +189,7 @@ export function useHtmlDocument(): HtmlDocumentState {
throw error; throw error;
} }
}, },
[audiobookAdapter, providerRef], [audiobookAdapter, apiKey, baseUrl, providerRef],
); );
const regenerateChapter = useCallback( const regenerateChapter = useCallback(
@ -204,6 +208,8 @@ export function useHtmlDocument(): HtmlDocumentState {
bookId, bookId,
format, format,
signal, signal,
apiKey,
baseUrl,
defaultProvider: providerRef, defaultProvider: providerRef,
settings, settings,
retryOptions, retryOptions,
@ -216,7 +222,7 @@ export function useHtmlDocument(): HtmlDocumentState {
throw error; throw error;
} }
}, },
[audiobookAdapter, providerRef], [audiobookAdapter, apiKey, baseUrl, providerRef],
); );
return useMemo( return useMemo(

View file

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

View file

@ -14,6 +14,7 @@ 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';
@ -98,6 +99,7 @@ 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 {
@ -105,7 +107,12 @@ export default function PDFViewerPage() {
setError('Document not found'); setError('Document not found');
return; return;
} }
const resolved = id as string; const resolved = await resolveDocumentId(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;
@ -118,18 +125,12 @@ 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 result = await setCurrentDocument(resolved); const loaded = await setCurrentDocument(resolved);
if (result === 'loaded') { if (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));
} }
@ -144,11 +145,11 @@ export default function PDFViewerPage() {
if (startedLoad) { if (startedLoad) {
inFlightDocIdRef.current = null; inFlightDocIdRef.current = null;
} }
if (startedLoad && loadSucceeded) { if (!didRedirect && startedLoad && loadSucceeded) {
setIsLoading(false); setIsLoading(false);
} }
} }
}, [isLoading, id, setCurrentDocument, stop]); }, [isLoading, id, router, setCurrentDocument, stop]);
useEffect(() => { useEffect(() => {
loadDocument(); loadDocument();

View file

@ -22,8 +22,10 @@ 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';
@ -54,16 +56,6 @@ 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.
@ -86,7 +78,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<SetCurrentDocumentResult>; setCurrentDocument: (id: string) => Promise<boolean>;
clearCurrDoc: () => void; clearCurrDoc: () => void;
// PDF functionality // PDF functionality
@ -147,6 +139,8 @@ export function usePdfDocument(): PdfDocumentState {
registerVisualPageChangeHandler, registerVisualPageChangeHandler,
} = useTTS(); } = useTTS();
const { const {
apiKey,
baseUrl,
providerRef, providerRef,
segmentPreloadDepthPages, segmentPreloadDepthPages,
ttsSegmentMaxBlockLength, ttsSegmentMaxBlockLength,
@ -164,11 +158,6 @@ 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;
@ -217,6 +206,17 @@ 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 server metadata and the browser blob cache. * Retrieves document from IndexedDB
* *
* @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<SetCurrentDocumentResult> => { const setCurrentDocument = useCallback(async (id: string): Promise<boolean> => {
// --- race-condition guard --- // --- race-condition guard ---
const seq = ++docLoadSeqRef.current; const seq = ++docLoadSeqRef.current;
docLoadAbortRef.current?.abort(); docLoadAbortRef.current?.abort();
@ -554,12 +554,13 @@ 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 'superseded'; // a newer load took over if (seq !== docLoadSeqRef.current) return false; // stale
if (!meta) { if (!meta) {
console.error('Document not found on server'); console.error('Document not found on server');
return 'failed'; return false;
} }
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);
@ -567,29 +568,28 @@ 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 'superseded'; // a newer load took over if (seq !== docLoadSeqRef.current) return false; // stale
if (doc.type !== 'pdf') { if (doc.type !== 'pdf') {
console.error('Document is not a PDF'); console.error('Document is not a PDF');
return 'failed'; return false;
} }
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 'loaded'; return true;
} catch (error) { } catch (error) {
// An aborted load means a newer selection (or unmount) took over; not a failure. if (error instanceof DOMException && error.name === 'AbortError') return false;
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 'failed'; return false;
} 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,6 +597,7 @@ export function usePdfDocument(): PdfDocumentState {
setCurrDocPages, setCurrDocPages,
setCurrDocText, setCurrDocText,
setPdfDocument, setPdfDocument,
fetchDocumentSettings,
resolveParsedDocumentState, resolveParsedDocumentState,
]); ]);
@ -604,11 +605,12 @@ export function usePdfDocument(): PdfDocumentState {
if (!currDocId) return; if (!currDocId) return;
setDocumentSettings(settings); setDocumentSettings(settings);
try { try {
await serverDocumentSettings.mutation.mutateAsync(settings); const updated = await putDocumentSettings(currDocId, 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, serverDocumentSettings.mutation]); }, [currDocId]);
const forceReparseParsedPdf = useCallback(async (): Promise<void> => { const forceReparseParsedPdf = useCallback(async (): Promise<void> => {
if (!currDocId) return; if (!currDocId) return;
@ -682,6 +684,8 @@ 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,
@ -694,7 +698,7 @@ export function usePdfDocument(): PdfDocumentState {
console.error('Error creating audiobook:', error); console.error('Error creating audiobook:', error);
throw error; throw error;
} }
}, [audiobookAdapter, providerRef]); }, [audiobookAdapter, apiKey, baseUrl, providerRef]);
/** /**
* Regenerates a specific chapter (page) of the PDF audiobook * Regenerates a specific chapter (page) of the PDF audiobook
@ -713,6 +717,8 @@ export function usePdfDocument(): PdfDocumentState {
bookId, bookId,
format, format,
signal, signal,
apiKey,
baseUrl,
defaultProvider: providerRef, defaultProvider: providerRef,
settings, settings,
}); });
@ -723,7 +729,7 @@ export function usePdfDocument(): PdfDocumentState {
console.error('Error regenerating page:', error); console.error('Error regenerating page:', error);
throw error; throw error;
} }
}, [audiobookAdapter, providerRef]); }, [audiobookAdapter, apiKey, baseUrl, providerRef]);
/** /**
* Effect hook to initialize TTS as non-EPUB mode * Effect hook to initialize TTS as non-EPUB mode

View file

@ -477,7 +477,10 @@ 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

@ -1,27 +0,0 @@
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,7 +45,6 @@ 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

@ -1,43 +0,0 @@
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,8 +64,6 @@ 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) => {
@ -77,9 +75,6 @@ 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

@ -1,52 +0,0 @@
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

@ -1,81 +0,0 @@
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,7 +183,10 @@ 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,7 +20,10 @@ 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, userFolders, userOnboarding, userPreferences } from '@openreader/database/schema'; import { audiobooks, documentSettings, documents, userDocumentProgress, 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,16 +26,14 @@ 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; folders: number; onboarding: number }> { ): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number }> {
const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount], [folderCount], [onboardingCount]] = const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount]] =
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 {
@ -44,8 +42,6 @@ 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,8 +12,6 @@ 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';
@ -67,8 +65,6 @@ 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
@ -111,8 +107,6 @@ 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;
@ -196,8 +190,6 @@ 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,15 +1,26 @@
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 { userOnboarding } from '@openreader/database/schema'; import { userPreferences } 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);
@ -27,28 +38,40 @@ export async function POST(req: NextRequest) {
const rows = await db const rows = await db
.select({ .select({
lastSeenAppVersion: userOnboarding.lastSeenAppVersion, dataJson: userPreferences.dataJson,
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
}) })
.from(userOnboarding) .from(userPreferences)
.where(eq(userOnboarding.userId, scope.ownerUserId)) .where(eq(userPreferences.userId, scope.ownerUserId))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
const lastSeenVersion = row?.lastSeenAppVersion ?? null; const existingPayload = deserializeUserPreferencesPayload(row?.dataJson);
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(userOnboarding) .insert(userPreferences)
.values({ .values({
userId: scope.ownerUserId, userId: scope.ownerUserId,
lastSeenAppVersion: currentVersion, dataJson,
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
updatedAt, updatedAt,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [userOnboarding.userId], target: [userPreferences.userId],
set: { set: {
lastSeenAppVersion: currentVersion, dataJson,
updatedAt, updatedAt,
}, },
}); });

View file

@ -1,78 +0,0 @@
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,6 +13,12 @@ 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';
@ -21,22 +27,6 @@ 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(),
@ -59,10 +49,11 @@ async function loadPreferenceNormalizationContext(): Promise<PreferenceNormaliza
function parseStoredPreferences( function parseStoredPreferences(
value: unknown, value: unknown,
context: PreferenceNormalizationContext, context: PreferenceNormalizationContext,
): { patch: SyncedPreferencesPatch; migrated: boolean } { ): { patch: SyncedPreferencesPatch; migrated: boolean; meta: UserPreferencesMeta } {
const payload = deserializePreferences(value); const payload = deserializeUserPreferencesPayload(value);
const meta = extractUserPreferencesMeta(payload);
const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true }); const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true });
return { ...sanitized, migrated: sanitized.migrated || '_meta' in payload }; return { ...sanitized, meta };
} }
function normalizeClientUpdatedAtMs(value: unknown): number { function normalizeClientUpdatedAtMs(value: unknown): number {
@ -89,6 +80,7 @@ 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) {
@ -97,14 +89,14 @@ export async function GET(req: NextRequest) {
.insert(userPreferences) .insert(userPreferences)
.values({ .values({
userId: scope.ownerUserId, userId: scope.ownerUserId,
dataJson: serializePreferencesForDb(storedPatch), dataJson: serializePreferencesForDb(storedPayload),
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt, clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
updatedAt, updatedAt,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [userPreferences.userId], target: [userPreferences.userId],
set: { set: {
dataJson: serializePreferencesForDb(storedPatch), dataJson: serializePreferencesForDb(storedPayload),
updatedAt, updatedAt,
}, },
}); });
@ -169,7 +161,8 @@ export async function PUT(req: NextRequest) {
} }
const mergedPatch = { ...existingPatch, ...patch }; const mergedPatch = { ...existingPatch, ...patch };
const dataJson = serializePreferencesForDb(mergedPatch); const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta);
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, useEffect, useState } from 'react'; import { ReactNode, 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,15 +25,6 @@ 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 { useOnboardingState } from '@/hooks/useOnboardingState'; import { updateAppConfig } from '@/lib/client/dexie';
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui'; import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
interface PrivacyModalProps { interface PrivacyModalProps {
@ -47,7 +47,6 @@ 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);
@ -63,7 +62,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
}, [isOpen]); }, [isOpen]);
const handleAccept = async () => { const handleAccept = async () => {
await mutation.mutateAsync({ privacyAccepted: true }); await updateAppConfig({ 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,8 +22,13 @@ 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,
@ -33,6 +38,8 @@ 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,
@ -212,6 +219,7 @@ 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);
@ -220,8 +228,10 @@ 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 { providerRef, providerType, ttsModel, ttsInstructions, updateConfigKey } = useConfig(); const { apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, updateConfig, 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);
@ -269,12 +279,13 @@ 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: false, allowBuiltInProviders: !restrictUserApiKeys,
}), [localProviderRef, localProviderType, modelValue, customModelInput, showAllProviderModels, sharedProviders]); }), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllProviderModels, sharedProviders, restrictUserApiKeys]);
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];
@ -290,11 +301,13 @@ 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, providerRef, providerType, ttsModel, ttsInstructions]); }, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
useEffect(() => { useEffect(() => {
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
@ -318,9 +331,17 @@ 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 () => {
@ -438,9 +459,19 @@ 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);
@ -450,7 +481,7 @@ export function SettingsModal({
} else { } else {
setCustomModelInput(''); setCustomModelInput('');
} }
}, [providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]); }, [apiKey, baseUrl, 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
@ -515,6 +546,13 @@ 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(':'))
@ -604,11 +642,53 @@ 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.
@ -693,6 +773,8 @@ 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);
@ -714,6 +796,10 @@ 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
@ -721,6 +807,16 @@ 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,8 +11,6 @@ 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 {
@ -23,8 +21,6 @@ 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),
}; };
} }
@ -90,8 +86,6 @@ 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,6 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useCallback, useEffect, useMemo, 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,
@ -12,9 +13,11 @@ import type {
SortDirection, SortDirection,
ViewMode, ViewMode,
} from '@/types/documents'; } from '@/types/documents';
import { useFolders } from '@/hooks/useFolders'; import {
import { useUserPreferences } from '@/hooks/useUserPreferences'; getDocumentListState,
import { useAuthSession } from '@/hooks/useAuthSession'; getDocumentRecentlyOpenedMap,
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';
@ -199,7 +202,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[]>([]); const [folders, setFolders] = useState<Folder[]>(cachedState?.folders ?? DEFAULT_STATE.folders);
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');
@ -210,7 +213,6 @@ 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<
@ -233,20 +235,18 @@ 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(() => {
if (preferencesQuery.isPending) return; let cancelled = false;
const saved = preferencesQuery.data?.preferences.documentListState; (async () => {
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,6 +257,7 @@ 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);
@ -265,7 +266,11 @@ 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(() => {
@ -273,7 +278,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const state: DocumentListState = { const state: DocumentListState = {
sortBy, sortBy,
sortDirection, sortDirection,
folders: [], folders,
collapsedFolders: [], collapsedFolders: [],
showHint, showHint,
viewMode, viewMode,
@ -283,34 +288,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
sidebarCollapsed: !sidebarOpen, sidebarCollapsed: !sidebarOpen,
}; };
cachedDocumentListState = state; cachedDocumentListState = state;
const saved = preferencesQuery.data?.preferences.documentListState; void saveDocumentListState(state);
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,
@ -318,8 +300,6 @@ 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.
@ -341,26 +321,28 @@ 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: doc.recentlyOpenedAt ?? 0, recentlyOpenedAt: recentlyOpenedById[documentIdentityKey(doc)] ?? 0,
})), })),
[rawDocuments], [rawDocuments, recentlyOpenedById],
); );
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);
@ -491,11 +473,10 @@ 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();
}, },
[folderState.move, selection], [selection],
); );
const handleMergeIntoFolder = useCallback( const handleMergeIntoFolder = useCallback(
@ -510,74 +491,50 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
[], [],
); );
const createFolderFromPending = useCallback(async () => { const createFolderFromPending = useCallback(() => {
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 documentIds = [...pendingMerge.sources, pendingMerge.target].map((doc) => doc.id); const folderId = `folder-${Date.now()}`;
const folderId = crypto.randomUUID(); setFolders((prev) => [
...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();
const { folder } = await folderState.create.mutateAsync({ }, [pendingMerge, newFolderName, selection]);
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`;
folderState.create.mutate({ name }); const folderId = `folder-${Date.now()}`;
setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]);
setNewFolderName(''); setNewFolderName('');
setManualFolderPrompt(false); setManualFolderPrompt(false);
setSidebarFilter('all'); setSidebarFilter(`folder:${folderId}`);
}, [folderState.create, newFolderName]); }, [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');
}, [folderState.remove, sidebarFilter]); }, [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();
}, [folderState.clear, selection, sidebarFilter]); }, [selection, sidebarFilter]);
// Status bar summary. // Status bar summary.
const summary = useMemo(() => { const summary = useMemo(() => {
@ -709,7 +666,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={handleDismissHint} onClick={() => setShowHint(false)}
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,
status.previewVersion || Number(doc.lastModified), Number(doc.lastModified),
previewKey, previewKey,
{ signal: controller.signal }, { signal: controller.signal },
).catch(() => null); ).catch(() => null);

View file

@ -0,0 +1,186 @@
'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,18 +1,27 @@
'use client'; 'use client';
import { createContext, useContext, useMemo, type ReactNode } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues } from '@/types/config'; import { useLiveQuery } from 'dexie-react-hooks';
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { 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 { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag, 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 { useUserPreferences } from '@/hooks/useUserPreferences'; import toast from 'react-hot-toast';
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;
@ -31,6 +40,7 @@ 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;
@ -44,82 +54,456 @@ 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 { data: session, isPending: isSessionPending } = useAuthSession(); const [isLoading, setIsLoading] = useState(true);
const sessionId = session?.user?.id ?? 'no-session'; const [isDBReady, setIsDBReady] = useState(false);
const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
const showAllProviderModels = useFeatureFlag('showAllProviderModels');
const didRunStartupMigrations = useRef(false);
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
const { 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 config = useMemo<AppConfigValues>(() => ({ const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
...APP_CONFIG_DEFAULTS, if (sessionKey === 'no-session') return;
...(query.data?.preferences ?? {}),
}), [query.data?.preferences]);
const effectiveProvider = useMemo(() => resolveProviderDefaults({ const syncedPatch: SyncedPreferencesPatch = {};
providerRef: config.providerRef, for (const key of SYNCED_PREFERENCE_KEYS) {
providerType: config.providerType, if (!(key in patch)) continue;
sharedProviders, const value = patch[key];
fallbackProviderRef: adminDefaultProviderRef, if (value === undefined) continue;
}), [adminDefaultProviderRef, config.providerRef, config.providerType, sharedProviders]); (syncedPatch as Record<SyncedPreferenceKey, unknown>)[key] = value;
}
if (Object.keys(syncedPatch).length === 0) return;
scheduleUserPreferencesSync(syncedPatch, sessionKey);
}, [sessionKey]);
// Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
useEffect(() => {
return () => {
cancelPendingPreferenceSync();
};
}, [sessionKey]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail;
const status = detail?.status;
if (status === 'opened') {
toast.dismiss('dexie-blocked');
return;
}
if (status === 'blocked' || status === 'stalled') {
const message =
'Database upgrade is waiting for another OpenReader tab. Close other OpenReader tabs and reload.';
toast.error(message, { id: 'dexie-blocked', duration: Infinity });
}
};
window.addEventListener('openreader:dexie', handler as EventListener);
return () => {
window.removeEventListener('openreader:dexie', handler as EventListener);
};
}, []);
useEffect(() => {
const initializeDB = async () => {
try {
setIsLoading(true);
await initDB();
setIsDBReady(true);
} catch (error) {
console.error('Error initializing Dexie:', error);
} finally {
setIsLoading(false);
}
};
initializeDB();
}, []);
useEffect(() => {
if (!isDBReady) return;
if (didRunStartupMigrations.current) return;
didRunStartupMigrations.current = true;
const run = async () => {
try {
await migrateLegacyDexieDocumentIdsToSha();
} catch (error) {
console.warn('Startup migrations failed:', error);
}
};
void run();
}, [isDBReady]);
const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
if (!isDBReady) return;
try {
const remote = await getUserPreferences({ signal });
if (!remote?.hasStoredPreferences) return;
if (!remote.preferences || Object.keys(remote.preferences).length === 0) return;
await updateAppConfig(remote.preferences as Partial<AppConfigRow>);
} catch (error) {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to load synced preferences:', error);
}
}, [isDBReady]);
useEffect(() => {
if (!isDBReady || isSessionPending) return;
const controller = new AbortController();
refreshSyncedPreferencesFromServer(controller.signal).catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Synced preferences refresh failed:', error);
});
return () => controller.abort();
}, [isDBReady, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
useEffect(() => {
if (!isDBReady) return;
let activeController: AbortController | null = null;
const onFocus = () => {
if (activeController) activeController.abort();
activeController = new AbortController();
refreshSyncedPreferencesFromServer(activeController.signal).catch((error) => {
if ((error as Error)?.name === 'AbortError') return;
console.warn('Focus synced preferences refresh failed:', error);
});
};
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
if (activeController) activeController.abort();
};
}, [isDBReady, refreshSyncedPreferencesFromServer]);
const appConfig = useLiveQuery(
async () => {
if (!isDBReady) return null;
const row = await db['app-config'].get('singleton');
return row ?? null;
},
[isDBReady],
null,
);
const config: AppConfigValues | null = useMemo(() => {
if (!appConfig) return null;
const { id, ...rest } = appConfig;
void id;
return { ...APP_CONFIG_DEFAULTS, ...rest };
}, [appConfig]);
useEffect(() => {
if (ttsProvidersTabDisabled && isDBReady && appConfig && !sharedProvidersLoading) {
const resetPatch: Partial<AppConfigRow> = {};
// When the provider tab is hidden, clear any user-set provider config back to
// "inherit the admin default" (empty) rather than baking in a concrete value.
if (appConfig.apiKey !== '') resetPatch.apiKey = '';
if (appConfig.baseUrl !== '') resetPatch.baseUrl = '';
if (appConfig.providerRef !== '') resetPatch.providerRef = '';
if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown';
if (appConfig.ttsModel !== '') resetPatch.ttsModel = '';
if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = '';
// Keep voice selection state intact so player/Audiobook voice pickers still
// work when the TTS providers tab is hidden. This reset is only for provider
// configuration fields.
if (Object.keys(resetPatch).length === 0) return;
updateAppConfig(resetPatch).catch((error) => {
console.warn('Failed to clear hidden TTS provider settings:', error);
});
queueSyncedPreferencePatch(resetPatch);
}
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
useEffect(() => {
if (restrictUserApiKeys && isDBReady && appConfig && !sharedProvidersLoading) {
const resetPatch: Partial<AppConfigRow> = {};
if (appConfig.apiKey !== '') resetPatch.apiKey = '';
if (appConfig.baseUrl !== '') resetPatch.baseUrl = '';
// Built-in providers aren't selectable in restricted mode. Clear any stale
// built-in selection (including the old 'custom-openai' default that used to
// be baked into every config) back to "inherit the admin default" so the
// user follows whatever shared provider the admin has configured.
if (isBuiltInTtsProviderId(appConfig.providerRef)) {
resetPatch.providerRef = '';
resetPatch.providerType = 'unknown';
resetPatch.ttsModel = '';
resetPatch.ttsInstructions = '';
}
if (Object.keys(resetPatch).length === 0) return;
updateAppConfig(resetPatch).catch((error) => {
console.warn('Failed to enforce restricted user API key mode:', error);
});
queueSyncedPreferencePatch(resetPatch);
}
}, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
useEffect(() => {
if (showAllProviderModels || !isDBReady || !appConfig || sharedProvidersLoading) return;
// Inheriting (empty providerRef): the effective model is resolved at read
// time, so there is nothing to persist/enforce here.
if (!appConfig.providerRef) return;
const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef,
providerType: appConfig.providerType,
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 effectiveTtsModel = config.ttsModel || effectiveProvider.defaultModel; const providerType = effectiveProvider.providerType;
const effectiveTtsInstructions = config.ttsInstructions || effectiveProvider.defaultInstructions; const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel;
const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions;
const updatePatch = async (patch: Partial<AppConfigValues>) => { useEffect(() => {
await mutation.mutateAsync(patch as SyncedPreferencesPatch); if (!isDBReady || !appConfig || sharedProvidersLoading) return;
// Only persist a resolved providerType for an explicitly chosen provider.
// While inheriting (empty providerRef) the type stays unset in storage.
if (!appConfig.providerRef) return;
if (appConfig.providerType === providerType) return;
const patch: Partial<AppConfigRow> = { providerType };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to persist resolved providerType:', error);
});
queueSyncedPreferencePatch(patch);
}, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]);
void _persistedProviderType;
useEffect(() => {
if (!isDBReady || !appConfig || sharedProvidersLoading) return;
// Inheriting (empty providerRef): the effective model is resolved at read
// time; don't write a concrete model into the "inherit" state.
if (!appConfig.providerRef) return;
const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef,
providerType: appConfig.providerType,
sharedProviders,
fallbackProviderRef: adminDefaultProviderRef,
});
if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
// Heal stale fallback model values that were written while the provider UI
// was disabled and shared provider context was unavailable.
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) return;
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to normalize shared-provider default model:', error);
});
queueSyncedPreferencePatch(patch);
}, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]);
/**
* Updates multiple configuration values simultaneously
* Only saves API credentials if they are explicitly set
*/
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
try {
setIsLoading(true);
const updates: Partial<AppConfigRow> = {};
if (newConfig.apiKey !== undefined) {
updates.apiKey = newConfig.apiKey;
}
if (newConfig.baseUrl !== undefined) {
updates.baseUrl = newConfig.baseUrl;
}
if (newConfig.viewType !== undefined) {
updates.viewType = newConfig.viewType;
}
await updateAppConfig(updates);
queueSyncedPreferencePatch(updates);
} catch (error) {
console.error('Error updating config:', error);
throw error;
} finally {
setIsLoading(false);
}
}; };
/**
* 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]) => {
const { syncPatch } = applyConfigUpdate({ try {
providerRef: effectiveProviderRef, setIsLoading(true);
providerType: effectiveProvider.providerType, const { storagePatch, syncPatch } = applyConfigUpdate({
ttsModel: effectiveTtsModel, providerRef: effectiveProviderRef,
savedVoices: config.savedVoices, providerType,
}, key, value); ttsModel: effectiveTtsModel,
await updatePatch(syncPatch); savedVoices,
}; }, key, value);
const isLoading = isSessionPending || query.isPending || mutation.isPending || sharedProvidersLoading; 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);
}
};
return ( return (
<ConfigContext.Provider value={{ <ConfigContext.Provider value={{
viewType: config.viewType, apiKey,
voiceSpeed: config.voiceSpeed, baseUrl,
audioPlayerSpeed: config.audioPlayerSpeed, viewType,
voice: config.voice, voiceSpeed,
skipBlank: config.skipBlank, audioPlayerSpeed,
epubTheme: config.epubTheme, voice,
segmentPreloadDepthPages: config.segmentPreloadDepthPages, skipBlank,
segmentPreloadSentenceLookahead: config.segmentPreloadSentenceLookahead, epubTheme,
ttsSegmentMaxBlockLength: config.ttsSegmentMaxBlockLength, segmentPreloadDepthPages,
headerMargin: config.headerMargin, segmentPreloadSentenceLookahead,
footerMargin: config.footerMargin, ttsSegmentMaxBlockLength,
leftMargin: config.leftMargin, headerMargin,
rightMargin: config.rightMargin, footerMargin,
leftMargin,
rightMargin,
providerRef: effectiveProviderRef, providerRef: effectiveProviderRef,
providerType: effectiveProvider.providerType, providerType,
ttsModel: effectiveTtsModel, ttsModel: effectiveTtsModel,
ttsInstructions: effectiveTtsInstructions, ttsInstructions: effectiveTtsInstructions,
savedVoices: config.savedVoices, savedVoices,
updateConfig,
updateConfigKey, updateConfigKey,
isLoading, isLoading,
isDBReady: !isSessionPending && !query.isPending, isDBReady,
pdfHighlightEnabled: config.pdfHighlightEnabled, pdfHighlightEnabled,
pdfWordHighlightEnabled: config.pdfWordHighlightEnabled, pdfWordHighlightEnabled,
epubHighlightEnabled: config.epubHighlightEnabled, epubHighlightEnabled,
epubWordHighlightEnabled: config.epubWordHighlightEnabled, epubWordHighlightEnabled,
htmlHighlightEnabled: config.htmlHighlightEnabled, htmlHighlightEnabled,
htmlWordHighlightEnabled: config.htmlWordHighlightEnabled, 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) throw new Error('useConfig must be used within a ConfigProvider'); if (context === undefined) {
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { 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,31 +102,10 @@ 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 uploadMutation.mutateAsync(files); const stored = await uploadServerDocuments(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];
@ -155,13 +134,20 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
}), }),
); );
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous) =>
mergeStoredDocuments(previous, stored),
);
return stored; return stored;
}, [uploadMutation]); }, [documentsQueryKey, queryClient]);
const deleteDocument = useCallback(async (id: string) => { const deleteDocument = useCallback(async (id: string) => {
await deleteMutation.mutateAsync(id); await deleteServerDocuments({ ids: [id] });
await evictCachedDocument(id); await evictCachedDocument(id);
}, [deleteMutation]); queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous = []) =>
previous.filter((document) => document.id !== id),
);
}, [documentsQueryKey, queryClient]);
return ( return (
<DocumentContext.Provider value={{ <DocumentContext.Provider value={{

View file

@ -2,15 +2,16 @@
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 { useOnboardingState } from '@/hooks/useOnboardingState'; import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/client/query-keys';
type OnboardingFlowContextValue = { type OnboardingFlowContextValue = {
changelogOpenSignal: number; changelogOpenSignal: number;
@ -18,14 +19,18 @@ 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 {
@ -36,8 +41,6 @@ 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),
}; };
} }
@ -50,22 +53,71 @@ 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({
queryKey: queryKeys.claimCounts(userId ?? 'no-session'),
queryFn: fetchClaimableCounts,
enabled: Boolean(userId && !isAnonymous),
});
const refetchClaimCounts = claimCountsQuery.refetch;
const { query: onboardingQuery } = useOnboardingState(); const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | 'migration' | null>(null);
const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | null>(null);
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>(EMPTY_CLAIM_COUNTS); const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>(EMPTY_CLAIM_COUNTS);
const [migrationCounts, setMigrationCounts] = useState<{ localCount: number; missingCount: number }>({
localCount: 0,
missingCount: 0,
});
const [changelogOpenSignal, setChangelogOpenSignal] = useState(0); const [changelogOpenSignal, setChangelogOpenSignal] = useState(0);
const pendingChangelogOpenRef = useRef(false); const pendingChangelogOpenRef = useRef(false);
@ -83,17 +135,9 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
); );
const runOnceFlow = useCallback(async () => { const runOnceFlow = useCallback(async () => {
// Wait until the onboarding state has actually loaded before deciding whether const local = await readLocalOnboardingSnapshot();
// 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 || Boolean(onboardingData.privacyAcceptedAtMs); const privacyAccepted = !privacyRequired || local.privacyAccepted;
const isClaimEligible = Boolean( const isClaimEligible = Boolean(
userId userId
@ -105,25 +149,26 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
let claimHasData = false; let claimHasData = false;
if (isClaimEligible) { if (isClaimEligible) {
claimCounts = (await refetchClaimCounts()).data ?? EMPTY_CLAIM_COUNTS; claimCounts = await fetchClaimableCounts();
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,
}); });
@ -138,13 +183,26 @@ 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, onboardingQuery.data, refetchClaimCounts, userId]); }, [isAnonymous, userId]);
runOnceFlowRef.current = runOnceFlow; runOnceFlowRef.current = runOnceFlow;
@ -156,6 +214,11 @@ 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();
@ -163,7 +226,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
void runFlow(); void runFlow();
}, [isAnonymous, onboardingQuery.data, runFlow, userId]); }, [isAnonymous, runFlow, userId]);
useEffect(() => { useEffect(() => {
const onPrivacyAccepted = () => { const onPrivacyAccepted = () => {
@ -210,6 +273,12 @@ 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 (for example config defaults). Falls back to the * mounts (e.g. Dexie initialization, 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,7 +33,8 @@ 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 { useDocumentProgress } from '@/hooks/useDocumentProgress'; import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/client/dexie';
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 {
@ -81,7 +82,6 @@ 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,6 +420,8 @@ 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,
@ -442,6 +444,8 @@ 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,
@ -462,7 +466,6 @@ 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';
@ -726,7 +729,6 @@ 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) => {
@ -1617,7 +1619,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setTTSModel(configTTSModel); setTTSModel(configTTSModel);
setTTSInstructions(configTTSInstructions); setTTSInstructions(configTTSInstructions);
} }
}, [configIsLoading, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]); }, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
const preloadGenerationSignatureRef = useRef<string>(''); const preloadGenerationSignatureRef = useRef<string>('');
useEffect(() => { useEffect(() => {
@ -1764,8 +1766,12 @@ 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,
@ -1908,6 +1914,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel, ttsModel,
ttsInstructions, ttsInstructions,
resolvedLanguage, resolvedLanguage,
openApiKey,
openApiBaseUrl,
configProviderRef, configProviderRef,
configProviderType, configProviderType,
providerModelPolicy.supportsInstructions, providerModelPolicy.supportsInstructions,
@ -2036,13 +2044,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setCurrentSentenceAlignment(playbackSource.manifest.alignment); setCurrentSentenceAlignment(playbackSource.manifest.alignment);
setCurrentWordIndex(null); setCurrentWordIndex(null);
} }
const sourceUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl; const audioUrl = 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) {
@ -2608,8 +2610,12 @@ 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,
@ -2897,8 +2903,12 @@ 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,
@ -3016,6 +3026,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
configProviderRef, configProviderRef,
configProviderType, configProviderType,
ttsModel, ttsModel,
openApiKey,
openApiBaseUrl,
providerModelPolicy.supportsInstructions, providerModelPolicy.supportsInstructions,
ttsInstructions, ttsInstructions,
resolvedLanguage, resolvedLanguage,
@ -3385,9 +3397,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
skipBackward, skipBackward,
}); });
// Load the server-backed last location for EPUB/PDF/HTML. // Load last location on mount for EPUB/PDF/HTML.
// Prefer server-backed progress when available, then fall back to local Dexie.
useEffect(() => { useEffect(() => {
if (!id || !progressQuery.data?.location) return; if (!id) 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) {
@ -3438,8 +3454,35 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
}; };
applyLocation(progressQuery.data.location); const load = async () => {
}, [id, isEPUB, currentReaderType, progressQuery.data?.location]); try {
const local = await getLastDocumentLocation(docId);
if (!cancelled && local) {
applyLocation(local);
}
} catch (error) {
console.warn('Error loading local last location:', error);
}
try {
const remote = await getDocumentProgress(docId);
if (!cancelled && remote?.location) {
await setLastDocumentLocation(docId, remote.location).catch((error) => {
console.warn('Error caching remote location locally:', error);
});
applyLocation(remote.location);
}
} catch (error) {
console.warn('Error loading remote progress:', error);
}
};
load();
return () => {
cancelled = true;
};
}, [id, isEPUB, currentReaderType]);
// Save current position periodically for non-EPUB readers. // Save current position periodically for non-EPUB readers.
useEffect(() => { useEffect(() => {
@ -3448,7 +3491,10 @@ 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(() => {
scheduleProgress({ setLastDocumentLocation(id as string, location).catch(error => {
console.warn('Error saving non-EPUB location:', error);
});
scheduleDocumentProgressSync({
documentId: id as string, documentId: id as string,
readerType: currentReaderType, readerType: currentReaderType,
location, location,
@ -3457,7 +3503,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
} }
}, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType, scheduleProgress]); }, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType]);
/** /**
* Renders the TTS context provider with its children * Renders the TTS context provider with its children

View file

@ -7,12 +7,16 @@ 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
@ -25,6 +29,8 @@ 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',
@ -50,7 +56,7 @@ export function useVoiceManagement(
model: ttsModel || 'tts-1', model: ttsModel || 'tts-1',
}).defaultVoices); }).defaultVoices);
} }
}, [providerRef, providerType, ttsModel]); }, [apiKey, baseUrl, providerRef, providerType, ttsModel]);
return { availableVoices, fetchVoices }; return { availableVoices, fetchVoices };
} }

View file

@ -35,6 +35,8 @@ 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;
}; };
@ -59,6 +61,8 @@ 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) => {
@ -127,6 +131,8 @@ 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,
@ -139,7 +145,7 @@ export function useEPUBAudiobook({
console.error('Error creating audiobook:', error); console.error('Error creating audiobook:', error);
throw error; throw error;
} }
}, [audiobookAdapter, providerRef]); }, [audiobookAdapter, apiKey, baseUrl, providerRef]);
const regenerateChapter = useCallback(async ( const regenerateChapter = useCallback(async (
chapterIndex: number, chapterIndex: number,
@ -155,6 +161,8 @@ export function useEPUBAudiobook({
bookId, bookId,
format, format,
signal, signal,
apiKey,
baseUrl,
defaultProvider: providerRef, defaultProvider: providerRef,
settings, settings,
}); });
@ -165,7 +173,7 @@ export function useEPUBAudiobook({
console.error('Error regenerating chapter:', error); console.error('Error regenerating chapter:', error);
throw error; throw error;
} }
}, [audiobookAdapter, providerRef]); }, [audiobookAdapter, apiKey, baseUrl, providerRef]);
return { return {
createFullAudioBook, createFullAudioBook,

View file

@ -0,0 +1,53 @@
'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,14 +3,33 @@
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 { useDocumentProgress } from '@/hooks/useDocumentProgress'; import { setLastDocumentLocation } from '@/lib/client/dexie';
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
import { type EpubLocation = string | number;
isDirectionalEpubLocation,
shouldNavigateToDifferentCfi, export function isDirectionalEpubLocation(location: EpubLocation): location is 'next' | 'prev' {
shouldPersistEpubLocation, return location === 'next' || location === 'prev';
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;
@ -35,7 +54,6 @@ 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;
@ -115,9 +133,10 @@ export function useEPUBLocationController({
return; return;
} }
// Save the server-backed location after the first real rendition update. // Save the location to IndexedDB if not initial
if (shouldPersistEpubLocation(documentId, locationRef.current)) { if (shouldPersistEpubLocation(documentId, locationRef.current)) {
scheduleProgress({ setLastDocumentLocation(documentId, location.toString());
scheduleDocumentProgressSync({
documentId, documentId,
readerType: 'epub', readerType: 'epub',
location: location.toString(), location: location.toString(),
@ -141,11 +160,8 @@ export function useEPUBLocationController({
safeRenditionNavigate, safeRenditionNavigate,
setIsEpub, setIsEpub,
shouldPauseRef, shouldPauseRef,
scheduleProgress,
skipToLocation, skipToLocation,
]); ]);
return handleLocationChanged; return handleLocationChanged;
} }
export { isDirectionalEpubLocation, shouldNavigateToDifferentCfi, shouldPersistEpubLocation };

View file

@ -0,0 +1,52 @@
'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

@ -0,0 +1,50 @@
'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,31 +1,52 @@
'use client'; 'use client';
import { useCallback } from 'react'; import { useCallback, useEffect, useState } 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 { query, mutation } = useDocumentSettings(documentId); const [settings, setSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
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;
const next: DocumentSettings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, { let next = DEFAULT_DOCUMENT_SETTINGS;
...settings, setSettings((prev) => {
schemaVersion: 1, next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
language, ...prev,
schemaVersion: 1,
language,
});
return next;
}); });
try { try {
await mutation.mutateAsync(next); const response = await putDocumentSettings(documentId, 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, mutation, settings]); }, [documentId]);
return { return {
language: settings.language ?? 'auto', language: settings.language ?? 'auto',

View file

@ -1,54 +0,0 @@
'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

@ -1,32 +0,0 @@
'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 };
}

View file

@ -1,103 +0,0 @@
'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

@ -1,47 +0,0 @@
'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

@ -1,29 +0,0 @@
'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,11 +85,7 @@ 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 });
const document = docs[0] ?? null; return 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 {
@ -443,13 +439,9 @@ 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<Response> => { const fetchFallback = async (): Promise<ArrayBuffer> => {
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') || '';
@ -459,7 +451,7 @@ export async function fetchDocumentContentResponse(id: string, options?: { signa
} }
throw new Error(`Failed to download document (status ${res.status})`); throw new Error(`Failed to download document (status ${res.status})`);
} }
return res; return res.arrayBuffer();
}; };
try { try {
@ -475,7 +467,7 @@ export async function fetchDocumentContentResponse(id: string, options?: { signa
} }
throw new Error(`Failed to download document (status ${directRes.status})`); throw new Error(`Failed to download document (status ${directRes.status})`);
} }
return directRes; return directRes.arrayBuffer();
} catch (error) { } catch (error) {
if (options?.signal?.aborted) throw error; if (options?.signal?.aborted) throw error;
return fetchFallback(); return fetchFallback();
@ -516,7 +508,6 @@ 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;
@ -565,14 +556,12 @@ 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 || '',
}; };
} }
@ -617,3 +606,4 @@ 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,6 +78,102 @@ 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 },
@ -120,3 +216,56 @@ 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,6 +29,8 @@ 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;
@ -45,6 +47,8 @@ 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;
@ -67,10 +71,14 @@ 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,
}; };
} }
@ -87,6 +95,8 @@ function createAudiobookAbortError(): Error {
export async function runAudiobookGeneration({ export async function runAudiobookGeneration({
adapter, adapter,
apiKey,
baseUrl,
defaultProvider, defaultProvider,
onProgress, onProgress,
signal, signal,
@ -110,7 +120,7 @@ export async function runAudiobookGeneration({
} }
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format); const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef); const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
let processedLength = 0; let processedLength = 0;
let bookId = providedBookId; let bookId = providedBookId;
@ -211,6 +221,8 @@ export async function regenerateAudiobookChapter({
bookId, bookId,
format, format,
signal, signal,
apiKey,
baseUrl,
defaultProvider, defaultProvider,
settings, settings,
retryOptions = { retryOptions = {
@ -226,7 +238,7 @@ export async function regenerateAudiobookChapter({
} }
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format); const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef); const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
return withRetry( return withRetry(
async () => { async () => {

View file

@ -1,28 +0,0 @@
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();
}

View file

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

View file

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

1285
src/lib/client/dexie.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,22 +0,0 @@
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,10 +1,11 @@
export type OnboardingStep = 'privacy' | 'claim' | 'changelog' | 'done'; export type OnboardingStep = 'privacy' | 'claim' | 'migration' | '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;
}; };
@ -17,6 +18,10 @@ 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

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

View file

@ -8,8 +8,6 @@ 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';
@ -117,7 +115,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, folders: 0, onboarding: 0 }; return { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0 };
} }
const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([ const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([
@ -131,16 +129,13 @@ 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 foldersClaimed = await transferUserFolders(unclaimedUserId, userId); const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed] = await Promise.all([
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
@ -173,30 +168,9 @@ 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,8 +61,6 @@ 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[];
@ -146,8 +144,6 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
exportedAtMs, exportedAtMs,
profileData, profileData,
preferences, preferences,
folders = [],
onboarding = null,
readingHistory, readingHistory,
ttsUsage, ttsUsage,
jobEvents, jobEvents,
@ -175,8 +171,6 @@ 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,11 +125,6 @@ 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

@ -0,0 +1,52 @@
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

@ -0,0 +1,45 @@
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,6 +29,8 @@ 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;
@ -53,7 +55,10 @@ 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;
} }
/** /**
@ -70,6 +75,8 @@ 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,
@ -94,6 +101,7 @@ 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',
@ -102,6 +110,8 @@ export function getAppConfigDefaults(): AppConfigValues {
showHint: true, showHint: true,
viewMode: 'grid', viewMode: 'grid',
}, },
privacyAccepted: false,
documentsMigrationPrompted: false,
}; };
} }

View file

@ -6,7 +6,6 @@ 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,7 +25,6 @@ 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 server preferences before reloading // Ensure the dismissal has been persisted to IndexedDB 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,14 +533,27 @@ 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 server preferences. // Wait for DocumentListState.showHint to persist in IndexedDB 'app-config' store
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 response = await fetch('/api/user/state/preferences', { cache: 'no-store' }); const openDb = () => new Promise<IDBDatabase>((resolve, reject) => {
if (!response.ok) return false; const req = indexedDB.open('openreader-db');
const item = await response.json() as { preferences?: { documentListState?: unknown } }; req.onsuccess = () => resolve(req.result);
const state = item.preferences?.documentListState; req.onerror = () => reject(req.error);
});
const db = await openDb();
const readConfig = () => new Promise<unknown>((resolve, reject) => {
const tx = db.transaction(['app-config'], 'readonly');
const store = tx.objectStore('app-config');
const getReq = store.get('singleton');
getReq.onsuccess = () => resolve(getReq.result);
getReq.onerror = () => reject(getReq.error);
});
const item = await readConfig();
db.close();
if (!item || typeof item !== 'object') return false;
const state = (item as { documentListState?: unknown }).documentListState;
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

@ -1,74 +0,0 @@
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

@ -0,0 +1,114 @@
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/lib/client/epub/location-controller'; } from '../../src/hooks/epub/useEPUBLocationController';
describe('EPUB location controller helpers', () => { describe('EPUB location controller helpers', () => {
test('detects directional locations', () => { test('detects directional locations', () => {

View file

@ -9,6 +9,7 @@ describe('onboarding flow resolver', () => {
privacyAccepted: false, privacyAccepted: false,
claimEligible: true, claimEligible: true,
claimHasData: true, claimHasData: true,
migrationRequired: true,
changelogPending: true, changelogPending: true,
}); });
@ -21,18 +22,33 @@ 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,
}); });
@ -45,6 +61,7 @@ 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({
@ -52,6 +69,7 @@ describe('onboarding flow resolver', () => {
privacyAccepted: false, privacyAccepted: false,
claimEligible: false, claimEligible: false,
claimHasData: false, claimHasData: false,
migrationRequired: false,
changelogPending: false, changelogPending: false,
}); });

View file

@ -0,0 +1,19 @@
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

@ -1,19 +0,0 @@
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,8 +25,6 @@ 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,
@ -151,8 +149,6 @@ 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,6 +533,7 @@ 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

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