Compare commits
No commits in common. "refactor/data-storage" and "main" have entirely different histories.
refactor/d
...
main
93 changed files with 3535 additions and 5816 deletions
|
|
@ -41,7 +41,7 @@ When a non-admin user picks a shared provider in **Settings → TTS Provider**:
|
|||
|
||||
- The API key / base URL fields are hidden — those credentials never leave the server.
|
||||
- The TTS request still goes through the user's browser, but the server replaces the slug with the matching admin row's decrypted key and base URL before calling the upstream provider.
|
||||
- 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`:
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ title: Stack
|
|||
- Interactions: `react-dnd`, `react-dropzone`
|
||||
- Server state: [TanStack Query](https://tanstack.com/query) (React Query v5)
|
||||
- Authentication: [Better Auth](https://www.better-auth.com/) client SDK
|
||||
- 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/)
|
||||
- Notifications: `react-hot-toast`
|
||||
- Document rendering:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const UI_ARCHITECTURE_FILES = [
|
|||
"src/components/doclist/**/*.{ts,tsx}",
|
||||
"src/components/documents/DocumentUploader.tsx",
|
||||
"src/components/documents/DocumentSelectionModal.tsx",
|
||||
"src/components/documents/DexieMigrationModal.tsx",
|
||||
"src/components/documents/ZoomControl.tsx",
|
||||
"src/components/player/**/*.{ts,tsx}",
|
||||
"src/components/reader/**/*.{ts,tsx}",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@
|
|||
"cmpstr": "^3.3.0",
|
||||
"compromise": "^14.15.1",
|
||||
"core-js": "^3.49.0",
|
||||
"dexie": "^4.4.3",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"epubjs": "^0.3.93",
|
||||
|
|
@ -100,8 +102,8 @@
|
|||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^15.5.19",
|
||||
"openapi-typescript": "^7.10.1",
|
||||
"postcss": "^8.5.15",
|
||||
"openapi-typescript": "^7.10.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.8"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class FakeControlPlane {
|
|||
private readonly artifactKeys = new Set<string>();
|
||||
private nextOpId = 1;
|
||||
|
||||
readonly deps = {
|
||||
readonly deps: ComputeWorkerRouteDeps = {
|
||||
orchestrator: {
|
||||
enqueueOrReuse: async (request) => this.enqueueOrReuse(request),
|
||||
markRunning: async (input) => this.updateState(input.opId, {
|
||||
|
|
@ -75,7 +75,7 @@ export class FakeControlPlane {
|
|||
},
|
||||
},
|
||||
artifactExists: async (key) => this.artifactKeys.has(key),
|
||||
} satisfies ComputeWorkerRouteDeps;
|
||||
};
|
||||
|
||||
seedState(state: ComputeState): void {
|
||||
this.stateByOpId.set(state.opId, state);
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ describe('orphan recovery', () => {
|
|||
});
|
||||
|
||||
const firstSweep = await recoverOrphanedOperations({
|
||||
operationStateStore: fake.deps.operationStateStore,
|
||||
orchestrator: fake.deps.orchestrator,
|
||||
operationStateStore: fake.deps.operationStateStore!,
|
||||
orchestrator: fake.deps.orchestrator!,
|
||||
whisperTimeoutMs: 30_000,
|
||||
pdfTimeoutMs: 300_000,
|
||||
opStaleMs: 1_800_000,
|
||||
|
|
@ -38,8 +38,8 @@ describe('orphan recovery', () => {
|
|||
vi.advanceTimersByTime(31_000);
|
||||
|
||||
const secondSweep = await recoverOrphanedOperations({
|
||||
operationStateStore: fake.deps.operationStateStore,
|
||||
orchestrator: fake.deps.orchestrator,
|
||||
operationStateStore: fake.deps.operationStateStore!,
|
||||
orchestrator: fake.deps.orchestrator!,
|
||||
whisperTimeoutMs: 30_000,
|
||||
pdfTimeoutMs: 300_000,
|
||||
opStaleMs: 1_800_000,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -85,13 +85,6 @@
|
|||
"when": 1780851107819,
|
||||
"tag": "0011_scheduled-tasks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1781466220153,
|
||||
"tag": "0012_user_additions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
|
@ -85,13 +85,6 @@
|
|||
"when": 1780851107505,
|
||||
"tag": "0011_scheduled-tasks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1781466219864,
|
||||
"tag": "0012_user_additions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
export const documents = usePostgres ? postgresSchema.documents : sqliteSchema.documents;
|
||||
export const userFolders = usePostgres ? postgresSchema.userFolders : sqliteSchema.userFolders;
|
||||
export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema.audiobooks;
|
||||
export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters;
|
||||
export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars;
|
||||
export const userJobEvents = usePostgres ? postgresSchema.userJobEvents : sqliteSchema.userJobEvents;
|
||||
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
|
||||
export const userOnboarding = usePostgres ? postgresSchema.userOnboarding : sqliteSchema.userOnboarding;
|
||||
export const documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings;
|
||||
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;
|
||||
export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews;
|
||||
|
|
|
|||
|
|
@ -4,18 +4,6 @@ import { user } from './schema_auth_postgres';
|
|||
|
||||
const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`;
|
||||
|
||||
export const userFolders = pgTable('user_folders', {
|
||||
id: text('id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
position: integer('position').notNull().default(0),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.id, table.userId] }),
|
||||
index('idx_user_folders_user_position').on(table.userId, table.position),
|
||||
]);
|
||||
|
||||
export const documents = pgTable('documents', {
|
||||
id: text('id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
|
|
@ -24,19 +12,11 @@ export const documents = pgTable('documents', {
|
|||
size: bigint('size', { mode: 'number' }).notNull(),
|
||||
lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
|
||||
filePath: text('file_path').notNull(),
|
||||
folderId: text('folder_id'),
|
||||
recentlyOpenedAt: bigint('recently_opened_at', { mode: 'number' }),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.id, table.userId] }),
|
||||
foreignKey({
|
||||
columns: [table.folderId, table.userId],
|
||||
foreignColumns: [userFolders.id, userFolders.userId],
|
||||
}),
|
||||
index('idx_documents_user_id').on(table.userId),
|
||||
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
|
||||
index('idx_documents_user_id_folder').on(table.userId, table.folderId),
|
||||
index('idx_documents_user_id_recently_opened').on(table.userId, table.recentlyOpenedAt),
|
||||
]);
|
||||
|
||||
export const audiobooks = pgTable('audiobooks', {
|
||||
|
|
@ -109,14 +89,6 @@ export const userPreferences = pgTable('user_preferences', {
|
|||
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||
});
|
||||
|
||||
export const userOnboarding = pgTable('user_onboarding', {
|
||||
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
|
||||
privacyAcceptedAtMs: bigint('privacy_accepted_at_ms', { mode: 'number' }),
|
||||
lastSeenAppVersion: text('last_seen_app_version'),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||
});
|
||||
|
||||
export const documentSettings = pgTable('document_settings', {
|
||||
documentId: text('document_id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
|
|
|
|||
|
|
@ -4,18 +4,6 @@ import { user } from './schema_auth_sqlite';
|
|||
|
||||
const SQLITE_NOW_MS = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||
|
||||
export const userFolders = sqliteTable('user_folders', {
|
||||
id: text('id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
position: integer('position').notNull().default(0),
|
||||
createdAt: integer('created_at').default(SQLITE_NOW_MS),
|
||||
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.id, table.userId] }),
|
||||
index('idx_user_folders_user_position').on(table.userId, table.position),
|
||||
]);
|
||||
|
||||
export const documents = sqliteTable('documents', {
|
||||
id: text('id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
|
|
@ -24,19 +12,11 @@ export const documents = sqliteTable('documents', {
|
|||
size: integer('size').notNull(),
|
||||
lastModified: integer('last_modified').notNull(),
|
||||
filePath: text('file_path').notNull(),
|
||||
folderId: text('folder_id'),
|
||||
recentlyOpenedAt: integer('recently_opened_at'),
|
||||
createdAt: integer('created_at').default(SQLITE_NOW_MS),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.id, table.userId] }),
|
||||
foreignKey({
|
||||
columns: [table.folderId, table.userId],
|
||||
foreignColumns: [userFolders.id, userFolders.userId],
|
||||
}),
|
||||
index('idx_documents_user_id').on(table.userId),
|
||||
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
|
||||
index('idx_documents_user_id_folder').on(table.userId, table.folderId),
|
||||
index('idx_documents_user_id_recently_opened').on(table.userId, table.recentlyOpenedAt),
|
||||
]);
|
||||
|
||||
export const audiobooks = sqliteTable('audiobooks', {
|
||||
|
|
@ -109,14 +89,6 @@ export const userPreferences = sqliteTable('user_preferences', {
|
|||
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||
});
|
||||
|
||||
export const userOnboarding = sqliteTable('user_onboarding', {
|
||||
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
|
||||
privacyAcceptedAtMs: integer('privacy_accepted_at_ms'),
|
||||
lastSeenAppVersion: text('last_seen_app_version'),
|
||||
createdAt: integer('created_at').default(SQLITE_NOW_MS),
|
||||
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||
});
|
||||
|
||||
export const documentSettings = sqliteTable('document_settings', {
|
||||
documentId: text('document_id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ importers:
|
|||
core-js:
|
||||
specifier: ^3.49.0
|
||||
version: 3.49.0
|
||||
dexie:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
dexie-react-hooks:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0(dexie@4.4.3)(react@19.2.7)
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
|
|
@ -2600,6 +2606,15 @@ packages:
|
|||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
dexie-react-hooks@4.4.0:
|
||||
resolution: {integrity: sha512-ObLXBS5+4BJU8vtSvBx6b9fY6zZYgniAtwxzjCHsUQadgbqYN6935X2/1TWw4Rf2N1aZV1io5/ziox4vKuxABA==}
|
||||
peerDependencies:
|
||||
dexie: '>=4.2.0-alpha.1 <5.0.0'
|
||||
react: '>=16'
|
||||
|
||||
dexie@4.4.3:
|
||||
resolution: {integrity: sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==}
|
||||
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
|
|
@ -7074,6 +7089,13 @@ snapshots:
|
|||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
dexie-react-hooks@4.4.0(dexie@4.4.3)(react@19.2.7):
|
||||
dependencies:
|
||||
dexie: 4.4.3
|
||||
react: 19.2.7
|
||||
|
||||
dexie@4.4.3: {}
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
||||
import { EPUBViewer } from '@/components/views/EPUBViewer';
|
||||
|
|
@ -14,6 +14,7 @@ import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
|||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
import { resolveDocumentId } from '@/lib/client/dexie';
|
||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||
|
|
@ -25,6 +26,7 @@ import { useEpubDocument } from './useEpubDocument';
|
|||
export default function EPUBPage() {
|
||||
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
||||
const { id } = useParams();
|
||||
const router = useRouter();
|
||||
const routeDocumentId = typeof id === 'string' ? id : undefined;
|
||||
const epubState = useEpubDocument(routeDocumentId);
|
||||
const {
|
||||
|
|
@ -60,13 +62,19 @@ export default function EPUBPage() {
|
|||
|
||||
const loadDocument = useCallback(async () => {
|
||||
console.log('Loading new epub (from page.tsx)');
|
||||
let didRedirect = false;
|
||||
let startedLoad = false;
|
||||
try {
|
||||
if (!routeDocumentId) {
|
||||
setError('Document not found');
|
||||
return;
|
||||
}
|
||||
const resolved = routeDocumentId;
|
||||
const resolved = await resolveDocumentId(routeDocumentId);
|
||||
if (resolved !== routeDocumentId) {
|
||||
didRedirect = true;
|
||||
router.replace(`/epub/${resolved}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedDocIdRef.current === resolved) {
|
||||
return;
|
||||
|
|
@ -87,11 +95,11 @@ export default function EPUBPage() {
|
|||
if (startedLoad) {
|
||||
inFlightDocIdRef.current = null;
|
||||
}
|
||||
if (startedLoad) {
|
||||
if (!didRedirect && startedLoad) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [routeDocumentId, setCurrentDocument, stop]);
|
||||
}, [routeDocumentId, router, setCurrentDocument, stop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
} = useTTS();
|
||||
// Configuration context to get TTS settings
|
||||
const {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
providerRef,
|
||||
ttsSegmentMaxBlockLength,
|
||||
epubTheme,
|
||||
|
|
@ -193,7 +195,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
}, [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
|
||||
* @throws {Error} When document data is empty or retrieval fails
|
||||
*/
|
||||
|
|
@ -476,6 +478,8 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
|||
const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({
|
||||
bookRef,
|
||||
tocRef,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
providerRef,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
||||
import { HTMLViewer } from '@/components/views/HTMLViewer';
|
||||
|
|
@ -9,6 +9,7 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
|||
import { Header } from '@/components/Header';
|
||||
import { useTTS } from "@/contexts/TTSContext";
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
import { resolveDocumentId } from '@/lib/client/dexie';
|
||||
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
||||
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||
|
|
@ -25,6 +26,7 @@ import { useHtmlDocument } from './useHtmlDocument';
|
|||
export default function HTMLPage() {
|
||||
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
||||
const { id } = useParams();
|
||||
const router = useRouter();
|
||||
const routeDocumentId = typeof id === 'string' ? id : undefined;
|
||||
const htmlState = useHtmlDocument();
|
||||
const {
|
||||
|
|
@ -61,13 +63,19 @@ export default function HTMLPage() {
|
|||
const loadDocument = useCallback(async () => {
|
||||
if (!isLoading) return;
|
||||
console.log('Loading new HTML document (from page.tsx)');
|
||||
let didRedirect = false;
|
||||
let startedLoad = false;
|
||||
try {
|
||||
if (!id) {
|
||||
setError('Document not found');
|
||||
return;
|
||||
}
|
||||
const resolved = 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) {
|
||||
return;
|
||||
|
|
@ -88,11 +96,11 @@ export default function HTMLPage() {
|
|||
if (startedLoad) {
|
||||
inFlightDocIdRef.current = null;
|
||||
}
|
||||
if (startedLoad) {
|
||||
if (!didRedirect && startedLoad) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [isLoading, id, setCurrentDocument, stop]);
|
||||
}, [isLoading, id, router, setCurrentDocument, stop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ function buildFullDocumentText(blocks: HtmlBlock[]): string {
|
|||
export function useHtmlDocument(): HtmlDocumentState {
|
||||
const { setText: setTTSText, stop, setIsEPUB } = useTTS();
|
||||
const {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
providerRef,
|
||||
ttsSegmentMaxBlockLength,
|
||||
} = useConfig();
|
||||
|
|
@ -171,6 +173,8 @@ export function useHtmlDocument(): HtmlDocumentState {
|
|||
try {
|
||||
return await runAudiobookGeneration({
|
||||
adapter: audiobookAdapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: providerRef,
|
||||
onProgress,
|
||||
signal,
|
||||
|
|
@ -185,7 +189,7 @@ export function useHtmlDocument(): HtmlDocumentState {
|
|||
throw error;
|
||||
}
|
||||
},
|
||||
[audiobookAdapter, providerRef],
|
||||
[audiobookAdapter, apiKey, baseUrl, providerRef],
|
||||
);
|
||||
|
||||
const regenerateChapter = useCallback(
|
||||
|
|
@ -204,6 +208,8 @@ export function useHtmlDocument(): HtmlDocumentState {
|
|||
bookId,
|
||||
format,
|
||||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: providerRef,
|
||||
settings,
|
||||
retryOptions,
|
||||
|
|
@ -216,7 +222,7 @@ export function useHtmlDocument(): HtmlDocumentState {
|
|||
throw error;
|
||||
}
|
||||
},
|
||||
[audiobookAdapter, providerRef],
|
||||
[audiobookAdapter, apiKey, baseUrl, providerRef],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||
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>
|
||||
<AppShell>
|
||||
<AppMain>{children}</AppMain>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { TTSAudiobookChapter } from '@/types/tts';
|
|||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
||||
import { resolveDocumentId } from '@/lib/client/dexie';
|
||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||
|
|
@ -98,6 +99,7 @@ export default function PDFViewerPage() {
|
|||
const loadDocument = useCallback(async () => {
|
||||
if (!isLoading) return; // Prevent calls when not loading new doc
|
||||
console.log('Loading new document (from page.tsx)');
|
||||
let didRedirect = false;
|
||||
let startedLoad = false;
|
||||
let loadSucceeded = false;
|
||||
try {
|
||||
|
|
@ -105,7 +107,12 @@ export default function PDFViewerPage() {
|
|||
setError('Document not found');
|
||||
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) {
|
||||
return;
|
||||
|
|
@ -118,18 +125,12 @@ export default function PDFViewerPage() {
|
|||
inFlightDocIdRef.current = resolved;
|
||||
stop(); // Reset TTS when loading new document
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const result = await setCurrentDocument(resolved);
|
||||
if (result === 'loaded') {
|
||||
const loaded = await setCurrentDocument(resolved);
|
||||
if (loaded) {
|
||||
loadSucceeded = true;
|
||||
loadedDocIdRef.current = resolved;
|
||||
break;
|
||||
}
|
||||
if (result === 'superseded') {
|
||||
// A newer load (or unmount) is now authoritative; it owns the loading
|
||||
// lifecycle. Bail without surfacing an error to avoid the spurious
|
||||
// "Failed to load" screen on first launch.
|
||||
return;
|
||||
}
|
||||
if (attempt === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
|
@ -144,11 +145,11 @@ export default function PDFViewerPage() {
|
|||
if (startedLoad) {
|
||||
inFlightDocIdRef.current = null;
|
||||
}
|
||||
if (startedLoad && loadSucceeded) {
|
||||
if (!didRedirect && startedLoad && loadSucceeded) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [isLoading, id, setCurrentDocument, stop]);
|
||||
}, [isLoading, id, router, setCurrentDocument, stop]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDocument();
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ import {
|
|||
ensureParsedPdfDocumentOperation,
|
||||
forceReparsePdfDocument,
|
||||
getDocumentMetadata,
|
||||
getDocumentSettings,
|
||||
getParsedPdfDocument,
|
||||
ParsedPdfNotReadyError,
|
||||
putDocumentSettings,
|
||||
subscribeParsedPdfDocumentEvents,
|
||||
} from '@/lib/client/api/documents';
|
||||
import { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf';
|
||||
|
|
@ -54,16 +56,6 @@ import type {
|
|||
} from '@/types/tts';
|
||||
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
||||
import { clampSegmentPreloadDepth } from '@/types/config';
|
||||
import { useDocumentSettings } from '@/hooks/useDocumentSettings';
|
||||
|
||||
/**
|
||||
* Outcome of a `setCurrentDocument` call.
|
||||
* - `loaded`: the document was fetched and is now the active document.
|
||||
* - `superseded`: the load was aborted/replaced by a newer load (or unmount).
|
||||
* A newer load is authoritative; callers must NOT treat this as an error.
|
||||
* - `failed`: a genuine failure (not found, wrong type, network error).
|
||||
*/
|
||||
export type SetCurrentDocumentResult = 'loaded' | 'superseded' | 'failed';
|
||||
|
||||
/**
|
||||
* Interface defining all available methods and properties for the PDF route.
|
||||
|
|
@ -86,7 +78,7 @@ export interface PdfDocumentState {
|
|||
parsedOverlayEnabled: boolean;
|
||||
setParsedOverlayEnabled: (enabled: boolean) => void;
|
||||
forceReparseParsedPdf: () => Promise<void>;
|
||||
setCurrentDocument: (id: string) => Promise<SetCurrentDocumentResult>;
|
||||
setCurrentDocument: (id: string) => Promise<boolean>;
|
||||
clearCurrDoc: () => void;
|
||||
|
||||
// PDF functionality
|
||||
|
|
@ -147,6 +139,8 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
registerVisualPageChangeHandler,
|
||||
} = useTTS();
|
||||
const {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
providerRef,
|
||||
segmentPreloadDepthPages,
|
||||
ttsSegmentMaxBlockLength,
|
||||
|
|
@ -164,11 +158,6 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
const [parseProgress, setParseProgress] = useState<PdfParseProgress | null>(null);
|
||||
const [, setActiveParseOpId] = useState<string | null>(null);
|
||||
const [documentSettings, setDocumentSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
|
||||
const serverDocumentSettings = useDocumentSettings(currDocId);
|
||||
useEffect(() => {
|
||||
if (!serverDocumentSettings.query.data?.settings) return;
|
||||
setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, serverDocumentSettings.query.data.settings));
|
||||
}, [serverDocumentSettings.query.data?.settings]);
|
||||
useEffect(() => {
|
||||
setDocumentLanguage(documentSettings.language ?? 'auto');
|
||||
lastPreparedPlaybackPageRef.current = null;
|
||||
|
|
@ -217,6 +206,17 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
pageTextCacheRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const fetchDocumentSettings = useCallback(async (documentId: string, signal: AbortSignal): Promise<void> => {
|
||||
try {
|
||||
const response = await getDocumentSettings(documentId, { signal });
|
||||
setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
console.warn('Failed to load document settings, using defaults:', error);
|
||||
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startParsedEventStream = useCallback((documentId: string, initialOpId: string) => {
|
||||
parseStreamAbortRef.current?.abort();
|
||||
parseSseCloseRef.current?.();
|
||||
|
|
@ -516,12 +516,12 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
|
||||
/**
|
||||
* Sets the current document based on its ID
|
||||
* Retrieves document from server metadata and the browser blob cache.
|
||||
* Retrieves document from IndexedDB
|
||||
*
|
||||
* @param {string} id - The unique identifier of the document to set
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const setCurrentDocument = useCallback(async (id: string): Promise<SetCurrentDocumentResult> => {
|
||||
const setCurrentDocument = useCallback(async (id: string): Promise<boolean> => {
|
||||
// --- race-condition guard ---
|
||||
const seq = ++docLoadSeqRef.current;
|
||||
docLoadAbortRef.current?.abort();
|
||||
|
|
@ -554,12 +554,13 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
||||
|
||||
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) {
|
||||
console.error('Document not found on server');
|
||||
return 'failed';
|
||||
return false;
|
||||
}
|
||||
if (meta.type === 'pdf') {
|
||||
void fetchDocumentSettings(id, controller.signal);
|
||||
void resolveParsedDocumentState(id, controller.signal).catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
console.error('Failed to resolve parsed PDF state:', error);
|
||||
|
|
@ -567,29 +568,28 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
}
|
||||
|
||||
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') {
|
||||
console.error('Document is not a PDF');
|
||||
return 'failed';
|
||||
return false;
|
||||
}
|
||||
|
||||
setCurrDocName(doc.name);
|
||||
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
|
||||
// buffer passed into the worker; we always pass clones to react-pdf.
|
||||
setCurrDocData(doc.data.slice(0));
|
||||
return 'loaded';
|
||||
return true;
|
||||
} catch (error) {
|
||||
// An aborted load means a newer selection (or unmount) took over; not a failure.
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return 'superseded';
|
||||
if (controller.signal.aborted) return 'superseded';
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return false;
|
||||
console.error('Failed to get document:', error);
|
||||
return 'failed';
|
||||
return false;
|
||||
} finally {
|
||||
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
|
||||
if (docLoadAbortRef.current === controller) {
|
||||
docLoadAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [
|
||||
setCurrDocId,
|
||||
setCurrDocName,
|
||||
|
|
@ -597,6 +597,7 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
setCurrDocPages,
|
||||
setCurrDocText,
|
||||
setPdfDocument,
|
||||
fetchDocumentSettings,
|
||||
resolveParsedDocumentState,
|
||||
]);
|
||||
|
||||
|
|
@ -604,11 +605,12 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
if (!currDocId) return;
|
||||
setDocumentSettings(settings);
|
||||
try {
|
||||
await serverDocumentSettings.mutation.mutateAsync(settings);
|
||||
const updated = await putDocumentSettings(currDocId, settings);
|
||||
setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, updated.settings));
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist document settings:', error);
|
||||
}
|
||||
}, [currDocId, serverDocumentSettings.mutation]);
|
||||
}, [currDocId]);
|
||||
|
||||
const forceReparseParsedPdf = useCallback(async (): Promise<void> => {
|
||||
if (!currDocId) return;
|
||||
|
|
@ -682,6 +684,8 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
try {
|
||||
return await runAudiobookGeneration({
|
||||
adapter: audiobookAdapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: providerRef,
|
||||
onProgress,
|
||||
signal,
|
||||
|
|
@ -694,7 +698,7 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [audiobookAdapter, providerRef]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||
|
||||
/**
|
||||
* Regenerates a specific chapter (page) of the PDF audiobook
|
||||
|
|
@ -713,6 +717,8 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
bookId,
|
||||
format,
|
||||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: providerRef,
|
||||
settings,
|
||||
});
|
||||
|
|
@ -723,7 +729,7 @@ export function usePdfDocument(): PdfDocumentState {
|
|||
console.error('Error regenerating page:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [audiobookAdapter, providerRef]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||
|
||||
/**
|
||||
* Effect hook to initialize TTS as non-EPUB mode
|
||||
|
|
|
|||
|
|
@ -477,7 +477,10 @@ export async function POST(request: NextRequest) {
|
|||
providerForError = requestedProvider;
|
||||
const credResolved = await resolveTtsCredentials({
|
||||
providerHeader: requestedProvider,
|
||||
apiKeyHeader: request.headers.get('x-openai-key'),
|
||||
baseUrlHeader: request.headers.get('x-openai-base-url'),
|
||||
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
||||
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
|
||||
});
|
||||
if ('error' in credResolved) {
|
||||
if (credResolved.error === 'no_shared_provider_configured') {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,6 @@ export async function GET(req: NextRequest) {
|
|||
status: 'ready',
|
||||
presignUrl,
|
||||
fallbackUrl,
|
||||
previewVersion: preview.eTag || String(doc.lastModified),
|
||||
...(directUrl ? { directUrl } : {}),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -64,8 +64,6 @@ export async function GET(req: NextRequest) {
|
|||
size: number;
|
||||
lastModified: number;
|
||||
filePath: string;
|
||||
folderId: string | null;
|
||||
recentlyOpenedAt: number | null;
|
||||
}>;
|
||||
|
||||
const results: BaseDocument[] = rows.map((doc) => {
|
||||
|
|
@ -77,9 +75,6 @@ export async function GET(req: NextRequest) {
|
|||
lastModified: Number(doc.lastModified),
|
||||
type,
|
||||
scope: 'user',
|
||||
folderId: doc.folderId ?? undefined,
|
||||
recentlyOpenedAt: doc.recentlyOpenedAt == null ? undefined : Number(doc.recentlyOpenedAt),
|
||||
contentVersion: doc.id,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +183,10 @@ export async function POST(request: NextRequest) {
|
|||
});
|
||||
const requestCreds = await resolveTtsCredentials({
|
||||
providerHeader: parsed.settings.providerRef,
|
||||
apiKeyHeader: request.headers.get('x-openai-key'),
|
||||
baseUrlHeader: request.headers.get('x-openai-base-url'),
|
||||
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
||||
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
|
||||
});
|
||||
if ('error' in requestCreds) {
|
||||
const status = requestCreds.error === 'no_shared_provider_configured' ? 503 : 404;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ export async function GET(req: NextRequest) {
|
|||
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||
const resolved = await resolveTtsCredentials({
|
||||
providerHeader: req.headers.get('x-tts-provider'),
|
||||
apiKeyHeader: req.headers.get('x-openai-key'),
|
||||
baseUrlHeader: req.headers.get('x-openai-base-url'),
|
||||
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
||||
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
|
||||
});
|
||||
|
||||
if ('error' in resolved) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||
import { claimAnonymousData } from '@/lib/server/user/claim-data';
|
||||
import { auth } from '@/lib/server/auth/auth';
|
||||
import { db } from '@openreader/database';
|
||||
import { audiobooks, documentSettings, documents, userDocumentProgress, 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 { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||
|
|
@ -26,16 +26,14 @@ async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
|
|||
|
||||
async function getClaimableCounts(
|
||||
unclaimedUserId: string,
|
||||
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number; folders: number; onboarding: number }> {
|
||||
const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount], [folderCount], [onboardingCount]] =
|
||||
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number }> {
|
||||
const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount]] =
|
||||
await Promise.all([
|
||||
db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)),
|
||||
db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)),
|
||||
db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)),
|
||||
db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)),
|
||||
db.select({ count: count() }).from(documentSettings).where(eq(documentSettings.userId, unclaimedUserId)),
|
||||
db.select({ count: count() }).from(userFolders).where(eq(userFolders.userId, unclaimedUserId)),
|
||||
db.select({ count: count() }).from(userOnboarding).where(eq(userOnboarding.userId, unclaimedUserId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
|
@ -44,8 +42,6 @@ async function getClaimableCounts(
|
|||
preferences: Number(preferencesCount?.count ?? 0),
|
||||
progress: Number(progressCount?.count ?? 0),
|
||||
documentSettings: Number(settingsCount?.count ?? 0),
|
||||
folders: Number(folderCount?.count ?? 0),
|
||||
onboarding: Number(onboardingCount?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ import {
|
|||
userDocumentProgress,
|
||||
userJobEvents,
|
||||
userPreferences,
|
||||
userFolders,
|
||||
userOnboarding,
|
||||
userTtsChars,
|
||||
} from '@openreader/database/schema';
|
||||
import * as authSchemaSqlite from '@openreader/database/schema-auth-sqlite';
|
||||
|
|
@ -67,8 +65,6 @@ export async function GET(req: NextRequest) {
|
|||
segmentVariants,
|
||||
userDocs,
|
||||
userAudiobooks,
|
||||
folders,
|
||||
onboarding,
|
||||
] = await Promise.all([
|
||||
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1),
|
||||
db
|
||||
|
|
@ -111,8 +107,6 @@ export async function GET(req: NextRequest) {
|
|||
.from(audiobooks)
|
||||
.where(eq(audiobooks.userId, userId))
|
||||
.orderBy(desc(audiobooks.createdAt)),
|
||||
db.select().from(userFolders).where(eq(userFolders.userId, userId)).orderBy(userFolders.position),
|
||||
db.select().from(userOnboarding).where(eq(userOnboarding.userId, userId)).limit(1),
|
||||
]);
|
||||
|
||||
const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
|
||||
|
|
@ -196,8 +190,6 @@ export async function GET(req: NextRequest) {
|
|||
exportedAtMs,
|
||||
profileData,
|
||||
preferences: prefs[0] ?? null,
|
||||
folders,
|
||||
onboarding: onboarding[0] ?? null,
|
||||
readingHistory: progress,
|
||||
ttsUsage,
|
||||
jobEvents,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,26 @@
|
|||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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 { nowTimestampMs } from '@/lib/shared/timestamps';
|
||||
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||
import {
|
||||
deserializeUserPreferencesPayload,
|
||||
extractUserPreferencesMeta,
|
||||
withUserPreferencesMeta,
|
||||
USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY,
|
||||
} from '@/lib/server/user/preferences-payload';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function serializePreferencesForDb(payload: Record<string, unknown>): Record<string, unknown> | string {
|
||||
if (process.env.POSTGRES_URL) return payload;
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const scope = await resolveUserStateScope(req);
|
||||
|
|
@ -27,28 +38,40 @@ export async function POST(req: NextRequest) {
|
|||
|
||||
const rows = await db
|
||||
.select({
|
||||
lastSeenAppVersion: userOnboarding.lastSeenAppVersion,
|
||||
dataJson: userPreferences.dataJson,
|
||||
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
|
||||
})
|
||||
.from(userOnboarding)
|
||||
.where(eq(userOnboarding.userId, scope.ownerUserId))
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, scope.ownerUserId))
|
||||
.limit(1);
|
||||
|
||||
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 nextMeta = {
|
||||
...existingMeta,
|
||||
[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY]: currentVersion,
|
||||
};
|
||||
const dataJson = serializePreferencesForDb(withUserPreferencesMeta(existingPayload, nextMeta));
|
||||
const updatedAt = nowTimestampMs();
|
||||
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
|
||||
|
||||
await db
|
||||
.insert(userOnboarding)
|
||||
.insert(userPreferences)
|
||||
.values({
|
||||
userId: scope.ownerUserId,
|
||||
lastSeenAppVersion: currentVersion,
|
||||
dataJson,
|
||||
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userOnboarding.userId],
|
||||
target: [userPreferences.userId],
|
||||
set: {
|
||||
lastSeenAppVersion: currentVersion,
|
||||
dataJson,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,12 @@ import {
|
|||
sanitizePreferencesPatch,
|
||||
type PreferenceNormalizationContext,
|
||||
} from '@/lib/server/user/preferences-normalize';
|
||||
import {
|
||||
deserializeUserPreferencesPayload,
|
||||
extractUserPreferencesMeta,
|
||||
withUserPreferencesMeta,
|
||||
type UserPreferencesMeta,
|
||||
} from '@/lib/server/user/preferences-payload';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
|
|
@ -21,22 +27,6 @@ function serializePreferencesForDb(payload: Record<string, unknown>): Record<str
|
|||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function deserializePreferences(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
|
||||
const [runtimeConfig, providers] = await Promise.all([
|
||||
getResolvedRuntimeConfig(),
|
||||
|
|
@ -59,10 +49,11 @@ async function loadPreferenceNormalizationContext(): Promise<PreferenceNormaliza
|
|||
function parseStoredPreferences(
|
||||
value: unknown,
|
||||
context: PreferenceNormalizationContext,
|
||||
): { patch: SyncedPreferencesPatch; migrated: boolean } {
|
||||
const payload = deserializePreferences(value);
|
||||
): { patch: SyncedPreferencesPatch; migrated: boolean; meta: UserPreferencesMeta } {
|
||||
const payload = deserializeUserPreferencesPayload(value);
|
||||
const meta = extractUserPreferencesMeta(payload);
|
||||
const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true });
|
||||
return { ...sanitized, migrated: sanitized.migrated || '_meta' in payload };
|
||||
return { ...sanitized, meta };
|
||||
}
|
||||
|
||||
function normalizeClientUpdatedAtMs(value: unknown): number {
|
||||
|
|
@ -89,6 +80,7 @@ export async function GET(req: NextRequest) {
|
|||
const row = rows[0];
|
||||
const stored = parseStoredPreferences(row?.dataJson, normalizationContext);
|
||||
const storedPatch = stored.patch;
|
||||
const storedPayload = withUserPreferencesMeta(storedPatch, stored.meta);
|
||||
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
|
||||
|
||||
if (row && stored.migrated) {
|
||||
|
|
@ -97,14 +89,14 @@ export async function GET(req: NextRequest) {
|
|||
.insert(userPreferences)
|
||||
.values({
|
||||
userId: scope.ownerUserId,
|
||||
dataJson: serializePreferencesForDb(storedPatch),
|
||||
dataJson: serializePreferencesForDb(storedPayload),
|
||||
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userPreferences.userId],
|
||||
set: {
|
||||
dataJson: serializePreferencesForDb(storedPatch),
|
||||
dataJson: serializePreferencesForDb(storedPayload),
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
|
@ -169,7 +161,8 @@ export async function PUT(req: NextRequest) {
|
|||
}
|
||||
|
||||
const mergedPatch = { ...existingPatch, ...patch };
|
||||
const dataJson = serializePreferencesForDb(mergedPatch);
|
||||
const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta);
|
||||
const dataJson = serializePreferencesForDb(payloadWithMeta);
|
||||
const updatedAt = nowTimestampMs();
|
||||
|
||||
await db
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RuntimeConfigProvider>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useOnboardingState } from '@/hooks/useOnboardingState';
|
||||
import { updateAppConfig } from '@/lib/client/dexie';
|
||||
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
|
||||
|
||||
interface PrivacyModalProps {
|
||||
|
|
@ -47,7 +47,6 @@ function PrivacyModalBody({ origin }: { origin: string }) {
|
|||
}
|
||||
|
||||
export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) {
|
||||
const { mutation } = useOnboardingState();
|
||||
const [origin, setOrigin] = useState('');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
|
||||
|
|
@ -63,7 +62,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
|
|||
}, [isOpen]);
|
||||
|
||||
const handleAccept = async () => {
|
||||
await mutation.mutateAsync({ privacyAccepted: true });
|
||||
await updateAppConfig({ privacyAccepted: true });
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event('openreader:privacyAccepted'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,13 @@ import { mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
|||
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
||||
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
||||
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
|
||||
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||
import {
|
||||
isBuiltInTtsProviderId,
|
||||
type TtsProviderType,
|
||||
} from '@/lib/shared/tts-provider-catalog';
|
||||
import {
|
||||
defaultBaseUrlForProviderType,
|
||||
defaultModelForProviderType,
|
||||
resolveProviderDefaults,
|
||||
resolveEffectiveProviderType,
|
||||
resolveTtsProviderModelPolicy,
|
||||
|
|
@ -33,6 +38,8 @@ import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
|
|||
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
|
||||
import { AdminTasksPanel } from '@/components/admin/AdminTasksPanel';
|
||||
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
||||
import { flushUserPreferencesSync } from '@/lib/client/api/user-state';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
|
||||
import {
|
||||
SidebarDialog,
|
||||
|
|
@ -212,6 +219,7 @@ export function SettingsModal({
|
|||
const runtimeConfig = useRuntimeConfig();
|
||||
const showAllProviderModels = runtimeConfig.showAllProviderModels;
|
||||
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
|
||||
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
|
||||
const isOpen = open;
|
||||
const setIsOpen = onOpenChange;
|
||||
const [isChangelogOpen, setIsChangelogOpen] = useState(false);
|
||||
|
|
@ -220,8 +228,10 @@ export function SettingsModal({
|
|||
const { theme, setTheme, applyCustomColors } = useTheme();
|
||||
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
|
||||
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 [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
||||
const [localProviderRef, setLocalProviderRef] = useState(providerRef);
|
||||
const [localProviderType, setLocalProviderType] = useState<TtsProviderType>(providerType);
|
||||
const [modelValue, setModelValue] = useState(ttsModel);
|
||||
|
|
@ -269,12 +279,13 @@ export function SettingsModal({
|
|||
} = useMemo(() => resolveTtsSettingsViewModel({
|
||||
providerRef: localProviderRef,
|
||||
providerType: localProviderType,
|
||||
apiKey: localApiKey,
|
||||
modelValue,
|
||||
customModelInput,
|
||||
showAllProviderModels,
|
||||
sharedProviders,
|
||||
allowBuiltInProviders: false,
|
||||
}), [localProviderRef, localProviderType, modelValue, customModelInput, showAllProviderModels, sharedProviders]);
|
||||
allowBuiltInProviders: !restrictUserApiKeys,
|
||||
}), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllProviderModels, sharedProviders, restrictUserApiKeys]);
|
||||
const isSharedSelected = Boolean(selectedSharedProvider);
|
||||
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
|
||||
// stomp their in-progress selection. On close, resetToCurrent re-syncs.
|
||||
if (isOpen) return;
|
||||
setLocalApiKey(apiKey);
|
||||
setLocalBaseUrl(baseUrl);
|
||||
setLocalProviderRef(providerRef);
|
||||
setLocalProviderType(providerType);
|
||||
setModelValue(ttsModel);
|
||||
setLocalTTSInstructions(ttsInstructions);
|
||||
}, [isOpen, providerRef, providerType, ttsModel, ttsInstructions]);
|
||||
}, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
|
||||
|
|
@ -318,9 +331,17 @@ export function SettingsModal({
|
|||
setModelValue(shared.defaultModel);
|
||||
}
|
||||
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
|
||||
setLocalApiKey('');
|
||||
setLocalBaseUrl('');
|
||||
setCustomModelInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBuiltInTtsProviderId(fallback.providerType)) {
|
||||
setModelValue(defaultModelForProviderType(fallback.providerType));
|
||||
setLocalBaseUrl(defaultBaseUrlForProviderType(fallback.providerType));
|
||||
setCustomModelInput('');
|
||||
}
|
||||
}, [selectedProviderOption, ttsProviders, sharedProviders]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
|
|
@ -438,9 +459,19 @@ export function SettingsModal({
|
|||
setShowDeleteAccountConfirm(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
|
||||
if (type === 'apiKey') {
|
||||
setLocalApiKey(value === '' ? '' : value);
|
||||
} else if (type === 'baseUrl') {
|
||||
setLocalBaseUrl(value === '' ? '' : value);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToCurrent = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setIsChangelogOpen(false);
|
||||
setLocalApiKey(apiKey);
|
||||
setLocalBaseUrl(baseUrl);
|
||||
setLocalProviderRef(providerRef);
|
||||
setLocalProviderType(providerType);
|
||||
setModelValue(ttsModel);
|
||||
|
|
@ -450,7 +481,7 @@ export function SettingsModal({
|
|||
} else {
|
||||
setCustomModelInput('');
|
||||
}
|
||||
}, [providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
|
||||
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
|
||||
|
||||
const [systemIsDark, setSystemIsDark] = useState(
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
|
@ -515,6 +546,13 @@ export function SettingsModal({
|
|||
model: modelValue,
|
||||
sharedProviders,
|
||||
});
|
||||
const shouldShowBaseUrl = !restrictUserApiKeys
|
||||
&& !isSharedSelected
|
||||
&& providerModelPolicy.isResolvedProviderType
|
||||
&& providerModelPolicy.providerType !== 'replicate'
|
||||
&& providerModelPolicy.providerType !== 'speech-sdk'
|
||||
&& (providerModelPolicy.providerType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
|
||||
const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected;
|
||||
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
|
||||
const selectedModelVersion = selectedModel?.id?.includes(':')
|
||||
? selectedModel.id.slice(selectedModel.id.indexOf(':'))
|
||||
|
|
@ -604,11 +642,53 @@ export function SettingsModal({
|
|||
setLocalProviderType(defaults.providerType);
|
||||
setModelValue(defaults.defaultModel);
|
||||
setLocalTTSInstructions(defaults.defaultInstructions);
|
||||
if (provider.shared) {
|
||||
// Shared admin provider — credentials live on the server.
|
||||
setLocalApiKey('');
|
||||
setLocalBaseUrl('');
|
||||
} else if (isBuiltInTtsProviderId(provider.providerType)) {
|
||||
setLocalBaseUrl(defaultBaseUrlForProviderType(provider.providerType));
|
||||
}
|
||||
setCustomModelInput('');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{restrictUserApiKeys && (
|
||||
<p className="text-xs text-soft">
|
||||
This instance restricts user API keys. TTS runs through admin-configured shared providers only.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{shouldShowBaseUrl && (
|
||||
<div className="space-y-1.5">
|
||||
<label className={fieldLabelClass}>
|
||||
API Base URL
|
||||
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={localBaseUrl}
|
||||
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
||||
placeholder="Using environment variable"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowApiKey && (
|
||||
<div className="space-y-1.5">
|
||||
<label className={fieldLabelClass}>
|
||||
API Key
|
||||
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={localApiKey}
|
||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||
placeholder="Using environment variable"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isSharedSelected && (
|
||||
<p className="text-xs text-soft">
|
||||
This is a shared provider configured by an admin. API key and base URL are managed server-side.
|
||||
|
|
@ -693,6 +773,8 @@ export function SettingsModal({
|
|||
providerRef: runtimeConfig.defaultTtsProvider,
|
||||
sharedProviders,
|
||||
});
|
||||
setLocalApiKey('');
|
||||
setLocalBaseUrl('');
|
||||
setLocalProviderRef(defaults.providerRef);
|
||||
setLocalProviderType(defaults.providerType);
|
||||
setModelValue(defaults.defaultModel);
|
||||
|
|
@ -714,6 +796,10 @@ export function SettingsModal({
|
|||
providerType: selectedProviderType,
|
||||
sharedProviders,
|
||||
});
|
||||
await updateConfig({
|
||||
apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
|
||||
baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
|
||||
});
|
||||
await updateConfigKey('providerRef', selectedProviderRef);
|
||||
await updateConfigKey('providerType', selectedProviderType);
|
||||
const finalModel = showAllProviderModels
|
||||
|
|
@ -721,6 +807,16 @@ export function SettingsModal({
|
|||
: defaults.defaultModel;
|
||||
await updateConfigKey('ttsModel', finalModel);
|
||||
await updateConfigKey('ttsInstructions', localTTSInstructions);
|
||||
// Push the change to the server immediately rather than waiting on
|
||||
// the debounce, so a quick reload can't lose the save. Surface
|
||||
// failures instead of silently dropping them.
|
||||
try {
|
||||
await flushUserPreferencesSync();
|
||||
} catch (error) {
|
||||
console.error('Failed to save TTS provider settings:', error);
|
||||
toast.error('Failed to save provider settings. Please try again.');
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ export type ClaimableCounts = {
|
|||
preferences: number;
|
||||
progress: number;
|
||||
documentSettings: number;
|
||||
folders: number;
|
||||
onboarding: number;
|
||||
};
|
||||
|
||||
function toClaimableCounts(value: unknown): ClaimableCounts {
|
||||
|
|
@ -23,8 +21,6 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
|
|||
preferences: Number(rec.preferences ?? 0),
|
||||
progress: Number(rec.progress ?? 0),
|
||||
documentSettings: Number(rec.documentSettings ?? 0),
|
||||
folders: Number(rec.folders ?? 0),
|
||||
onboarding: Number(rec.onboarding ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -90,8 +86,6 @@ export default function ClaimDataModal({
|
|||
<li>{claimableCounts.preferences} preference set(s)</li>
|
||||
<li>{claimableCounts.progress} reading progress record(s)</li>
|
||||
<li>{claimableCounts.documentSettings} document setting(s)</li>
|
||||
<li>{claimableCounts.folders} folder(s)</li>
|
||||
<li>{claimableCounts.onboarding} onboarding state record(s)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'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 type {
|
||||
DocumentListDocument,
|
||||
|
|
@ -12,9 +13,11 @@ import type {
|
|||
SortDirection,
|
||||
ViewMode,
|
||||
} from '@/types/documents';
|
||||
import { useFolders } from '@/hooks/useFolders';
|
||||
import { useUserPreferences } from '@/hooks/useUserPreferences';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import {
|
||||
getDocumentListState,
|
||||
getDocumentRecentlyOpenedMap,
|
||||
saveDocumentListState,
|
||||
} from '@/lib/client/dexie';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
|
||||
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
|
||||
|
|
@ -199,7 +202,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
normalizeViewMode(cachedState?.viewMode ?? DEFAULT_STATE.viewMode),
|
||||
);
|
||||
const [iconSize, setIconSize] = useState<IconSize>(cachedState?.iconSize ?? DEFAULT_STATE.iconSize);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [folders, setFolders] = useState<Folder[]>(cachedState?.folders ?? DEFAULT_STATE.folders);
|
||||
const [showHint, setShowHint] = useState(cachedState?.showHint ?? true);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(cachedState?.sidebarWidth ?? DEFAULT_STATE.sidebarWidth);
|
||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>(cachedState?.sidebarFilter ?? 'all');
|
||||
|
|
@ -210,7 +213,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(cachedState !== null);
|
||||
const preferenceWriteTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
|
||||
const [pendingMerge, setPendingMerge] = useState<
|
||||
|
|
@ -233,20 +235,18 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
deleteDocument,
|
||||
isHTMLLoading,
|
||||
} = useDocuments();
|
||||
const { data: session, isPending: isSessionPending } = useAuthSession();
|
||||
const sessionId = session?.user?.id ?? 'no-session';
|
||||
const { query: preferencesQuery, mutation: preferencesMutation } = useUserPreferences(sessionId, !isSessionPending);
|
||||
const persistPreferences = preferencesMutation.mutate;
|
||||
const folderState = useFolders();
|
||||
|
||||
// Load saved state.
|
||||
useEffect(() => {
|
||||
if (preferencesQuery.isPending) return;
|
||||
const saved = preferencesQuery.data?.preferences.documentListState;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const saved = await getDocumentListState();
|
||||
if (cancelled) return;
|
||||
if (saved) {
|
||||
cachedDocumentListState = saved;
|
||||
setSortBy(saved.sortBy);
|
||||
setSortDirection(saved.sortDirection);
|
||||
setFolders(saved.folders ?? []);
|
||||
setShowHint(saved.showHint ?? true);
|
||||
setViewMode(normalizeViewMode(saved.viewMode));
|
||||
setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize);
|
||||
|
|
@ -257,6 +257,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
cachedDocumentListState = null;
|
||||
setSortBy(DEFAULT_STATE.sortBy);
|
||||
setSortDirection(DEFAULT_STATE.sortDirection);
|
||||
setFolders(DEFAULT_STATE.folders);
|
||||
setShowHint(DEFAULT_STATE.showHint);
|
||||
setViewMode(DEFAULT_STATE.viewMode);
|
||||
setIconSize(DEFAULT_STATE.iconSize);
|
||||
|
|
@ -265,7 +266,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
setSidebarOpen(!DEFAULT_STATE.sidebarCollapsed);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, [preferencesQuery.data?.preferences.documentListState, preferencesQuery.isPending]);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Persist.
|
||||
useEffect(() => {
|
||||
|
|
@ -273,7 +278,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
const state: DocumentListState = {
|
||||
sortBy,
|
||||
sortDirection,
|
||||
folders: [],
|
||||
folders,
|
||||
collapsedFolders: [],
|
||||
showHint,
|
||||
viewMode,
|
||||
|
|
@ -283,34 +288,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
sidebarCollapsed: !sidebarOpen,
|
||||
};
|
||||
cachedDocumentListState = state;
|
||||
const saved = preferencesQuery.data?.preferences.documentListState;
|
||||
if (
|
||||
saved
|
||||
&& saved.sortBy === state.sortBy
|
||||
&& saved.sortDirection === state.sortDirection
|
||||
&& (saved.showHint ?? true) === state.showHint
|
||||
&& normalizeViewMode(saved.viewMode) === state.viewMode
|
||||
&& (saved.iconSize ?? DEFAULT_STATE.iconSize) === state.iconSize
|
||||
&& (saved.sidebarWidth ?? DEFAULT_STATE.sidebarWidth) === state.sidebarWidth
|
||||
&& (saved.sidebarFilter ?? 'all') === state.sidebarFilter
|
||||
&& (saved.sidebarCollapsed ?? false) === state.sidebarCollapsed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (preferenceWriteTimer.current) clearTimeout(preferenceWriteTimer.current);
|
||||
preferenceWriteTimer.current = setTimeout(() => {
|
||||
persistPreferences({ documentListState: state });
|
||||
preferenceWriteTimer.current = null;
|
||||
}, 250);
|
||||
return () => {
|
||||
if (preferenceWriteTimer.current) {
|
||||
clearTimeout(preferenceWriteTimer.current);
|
||||
preferenceWriteTimer.current = null;
|
||||
}
|
||||
};
|
||||
void saveDocumentListState(state);
|
||||
}, [
|
||||
sortBy,
|
||||
sortDirection,
|
||||
folders,
|
||||
showHint,
|
||||
viewMode,
|
||||
iconSize,
|
||||
|
|
@ -318,8 +300,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
sidebarFilter,
|
||||
sidebarOpen,
|
||||
isInitialized,
|
||||
persistPreferences,
|
||||
preferencesQuery.data?.preferences.documentListState,
|
||||
]);
|
||||
|
||||
// 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],
|
||||
);
|
||||
const recentlyOpenedById = useLiveQuery<Record<string, number>, Record<string, number>>(
|
||||
async () => {
|
||||
try {
|
||||
return await getDocumentRecentlyOpenedMap();
|
||||
} catch (err) {
|
||||
console.warn('Failed to load recently opened cache metadata:', err);
|
||||
return {};
|
||||
}
|
||||
},
|
||||
[rawDocumentIdsKey],
|
||||
{},
|
||||
);
|
||||
|
||||
const allDocuments: DocumentListDocument[] = useMemo(
|
||||
() =>
|
||||
rawDocuments.map((doc) => ({
|
||||
...doc,
|
||||
recentlyOpenedAt: 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 map = new Map<string, DocumentListDocument>();
|
||||
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] };
|
||||
}),
|
||||
);
|
||||
folderState.move.mutate({ documentIds: item.docs.map((doc) => doc.id), folderId });
|
||||
setSidebarFilter(`folder:${folderId}`);
|
||||
selection.clear();
|
||||
},
|
||||
[folderState.move, selection],
|
||||
[selection],
|
||||
);
|
||||
|
||||
const handleMergeIntoFolder = useCallback(
|
||||
|
|
@ -510,74 +491,50 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
[],
|
||||
);
|
||||
|
||||
const createFolderFromPending = useCallback(async () => {
|
||||
const createFolderFromPending = useCallback(() => {
|
||||
if (!pendingMerge) return;
|
||||
const name =
|
||||
newFolderName.trim() ||
|
||||
generateDefaultFolderName(pendingMerge.sources[0], pendingMerge.target);
|
||||
const documentIds = [...pendingMerge.sources, pendingMerge.target].map((doc) => doc.id);
|
||||
const folderId = crypto.randomUUID();
|
||||
const folderId = `folder-${Date.now()}`;
|
||||
setFolders((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: folderId,
|
||||
name,
|
||||
documents: [
|
||||
...pendingMerge.sources.map((d) => ({ ...d, folderId })),
|
||||
{ ...pendingMerge.target, folderId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
setPendingMerge(null);
|
||||
setNewFolderName('');
|
||||
setShowHint(false);
|
||||
setSidebarFilter(`folder:${folderId}`);
|
||||
selection.clear();
|
||||
const { folder } = await folderState.create.mutateAsync({
|
||||
id: folderId,
|
||||
name,
|
||||
documentIds,
|
||||
});
|
||||
setSidebarFilter(`folder:${folder.id}`);
|
||||
}, [folderState.create, pendingMerge, newFolderName, selection]);
|
||||
|
||||
const handleDismissHint = useCallback(() => {
|
||||
setShowHint(false);
|
||||
persistPreferences({
|
||||
documentListState: {
|
||||
sortBy,
|
||||
sortDirection,
|
||||
folders: [],
|
||||
collapsedFolders: [],
|
||||
showHint: false,
|
||||
viewMode,
|
||||
iconSize,
|
||||
sidebarWidth,
|
||||
sidebarFilter,
|
||||
sidebarCollapsed: !sidebarOpen,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
iconSize,
|
||||
persistPreferences,
|
||||
sidebarFilter,
|
||||
sidebarOpen,
|
||||
sidebarWidth,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
viewMode,
|
||||
]);
|
||||
}, [pendingMerge, newFolderName, selection]);
|
||||
|
||||
const createManualFolder = useCallback(() => {
|
||||
const name = newFolderName.trim() || `New Folder`;
|
||||
folderState.create.mutate({ name });
|
||||
const folderId = `folder-${Date.now()}`;
|
||||
setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]);
|
||||
setNewFolderName('');
|
||||
setManualFolderPrompt(false);
|
||||
setSidebarFilter('all');
|
||||
}, [folderState.create, newFolderName]);
|
||||
setSidebarFilter(`folder:${folderId}`);
|
||||
}, [newFolderName]);
|
||||
|
||||
const handleDeleteFolder = useCallback((folderId: string) => {
|
||||
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
||||
folderState.remove.mutate(folderId);
|
||||
if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all');
|
||||
}, [folderState.remove, sidebarFilter]);
|
||||
}, [sidebarFilter]);
|
||||
|
||||
const handleClearFolders = useCallback(() => {
|
||||
setFolders([]);
|
||||
folderState.clear.mutate();
|
||||
if (sidebarFilter.startsWith('folder:')) setSidebarFilter('all');
|
||||
setClearFoldersPrompt(false);
|
||||
selection.clear();
|
||||
}, [folderState.clear, selection, sidebarFilter]);
|
||||
}, [selection, sidebarFilter]);
|
||||
|
||||
// Status bar summary.
|
||||
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.
|
||||
</p>
|
||||
<IconButton
|
||||
onClick={handleDismissHint}
|
||||
onClick={() => setShowHint(false)}
|
||||
size="xs"
|
||||
className="h-6 w-6"
|
||||
aria-label="Dismiss hint"
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
|
|||
if (status.kind === 'ready') {
|
||||
const primedUrl = await primeDocumentPreviewCache(
|
||||
doc.id,
|
||||
status.previewVersion || Number(doc.lastModified),
|
||||
Number(doc.lastModified),
|
||||
previewKey,
|
||||
{ signal: controller.signal },
|
||||
).catch(() => null);
|
||||
|
|
|
|||
186
src/components/documents/DexieMigrationModal.tsx
Normal file
186
src/components/documents/DexieMigrationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,27 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues } from '@/types/config';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie';
|
||||
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
|
||||
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
import { 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 { 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 { useSharedProviders } from '@/hooks/useSharedProviders';
|
||||
import { useUserPreferences } from '@/hooks/useUserPreferences';
|
||||
import type { SyncedPreferencesPatch } from '@/types/user-state';
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
export type { ViewType } from '@/types/config';
|
||||
|
||||
/** Configuration values for the application */
|
||||
|
||||
/** Interface defining the configuration context shape and functionality */
|
||||
interface ConfigContextType {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
viewType: ViewType;
|
||||
voiceSpeed: number;
|
||||
audioPlayerSpeed: number;
|
||||
|
|
@ -31,6 +40,7 @@ interface ConfigContextType {
|
|||
ttsModel: string;
|
||||
ttsInstructions: string;
|
||||
savedVoices: SavedVoices;
|
||||
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
|
||||
updateConfigKey: <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
isDBReady: boolean;
|
||||
|
|
@ -44,82 +54,456 @@ interface ConfigContextType {
|
|||
|
||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Provider component for application configuration
|
||||
* Manages global configuration state and persistence
|
||||
* @param {Object} props - Component props
|
||||
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
||||
*/
|
||||
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||
const { data: session, isPending: isSessionPending } = useAuthSession();
|
||||
const sessionId = session?.user?.id ?? 'no-session';
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDBReady, setIsDBReady] = useState(false);
|
||||
const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
|
||||
const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
|
||||
const showAllProviderModels = useFeatureFlag('showAllProviderModels');
|
||||
const didRunStartupMigrations = useRef(false);
|
||||
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
|
||||
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
|
||||
const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders();
|
||||
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
||||
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
||||
// The instance/admin default provider. An empty user providerRef "inherits"
|
||||
// this, resolved (admin slug -> concrete provider) where the value is used.
|
||||
const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider;
|
||||
const { query, mutation } = useUserPreferences(sessionId, !isSessionPending);
|
||||
|
||||
const config = useMemo<AppConfigValues>(() => ({
|
||||
...APP_CONFIG_DEFAULTS,
|
||||
...(query.data?.preferences ?? {}),
|
||||
}), [query.data?.preferences]);
|
||||
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
||||
if (sessionKey === 'no-session') return;
|
||||
|
||||
const effectiveProvider = useMemo(() => resolveProviderDefaults({
|
||||
providerRef: config.providerRef,
|
||||
providerType: config.providerType,
|
||||
sharedProviders,
|
||||
fallbackProviderRef: adminDefaultProviderRef,
|
||||
}), [adminDefaultProviderRef, config.providerRef, config.providerType, sharedProviders]);
|
||||
const syncedPatch: SyncedPreferencesPatch = {};
|
||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||
if (!(key in patch)) continue;
|
||||
const value = patch[key];
|
||||
if (value === undefined) continue;
|
||||
(syncedPatch as Record<SyncedPreferenceKey, unknown>)[key] = value;
|
||||
}
|
||||
if (Object.keys(syncedPatch).length === 0) return;
|
||||
scheduleUserPreferencesSync(syncedPatch, sessionKey);
|
||||
}, [sessionKey]);
|
||||
|
||||
// Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelPendingPreferenceSync();
|
||||
};
|
||||
}, [sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail;
|
||||
const status = detail?.status;
|
||||
if (status === 'opened') {
|
||||
toast.dismiss('dexie-blocked');
|
||||
return;
|
||||
}
|
||||
if (status === 'blocked' || status === 'stalled') {
|
||||
const message =
|
||||
'Database upgrade is waiting for another OpenReader tab. Close other OpenReader tabs and reload.';
|
||||
toast.error(message, { id: 'dexie-blocked', duration: Infinity });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('openreader:dexie', handler as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('openreader:dexie', handler as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeDB = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await initDB();
|
||||
setIsDBReady(true);
|
||||
} catch (error) {
|
||||
console.error('Error initializing Dexie:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeDB();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDBReady) return;
|
||||
if (didRunStartupMigrations.current) return;
|
||||
didRunStartupMigrations.current = true;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
await migrateLegacyDexieDocumentIdsToSha();
|
||||
} catch (error) {
|
||||
console.warn('Startup migrations failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
}, [isDBReady]);
|
||||
|
||||
const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
|
||||
if (!isDBReady) return;
|
||||
try {
|
||||
const remote = await getUserPreferences({ signal });
|
||||
if (!remote?.hasStoredPreferences) return;
|
||||
if (!remote.preferences || Object.keys(remote.preferences).length === 0) return;
|
||||
await updateAppConfig(remote.preferences as Partial<AppConfigRow>);
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') return;
|
||||
console.warn('Failed to load synced preferences:', error);
|
||||
}
|
||||
}, [isDBReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDBReady || isSessionPending) return;
|
||||
const controller = new AbortController();
|
||||
refreshSyncedPreferencesFromServer(controller.signal).catch((error) => {
|
||||
if ((error as Error)?.name === 'AbortError') return;
|
||||
console.warn('Synced preferences refresh failed:', error);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [isDBReady, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDBReady) return;
|
||||
let activeController: AbortController | null = null;
|
||||
const onFocus = () => {
|
||||
if (activeController) activeController.abort();
|
||||
activeController = new AbortController();
|
||||
refreshSyncedPreferencesFromServer(activeController.signal).catch((error) => {
|
||||
if ((error as Error)?.name === 'AbortError') return;
|
||||
console.warn('Focus synced preferences refresh failed:', error);
|
||||
});
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
if (activeController) activeController.abort();
|
||||
};
|
||||
}, [isDBReady, refreshSyncedPreferencesFromServer]);
|
||||
|
||||
const appConfig = useLiveQuery(
|
||||
async () => {
|
||||
if (!isDBReady) return null;
|
||||
const row = await db['app-config'].get('singleton');
|
||||
return row ?? null;
|
||||
},
|
||||
[isDBReady],
|
||||
null,
|
||||
);
|
||||
|
||||
const config: AppConfigValues | null = useMemo(() => {
|
||||
if (!appConfig) return null;
|
||||
const { id, ...rest } = appConfig;
|
||||
void id;
|
||||
return { ...APP_CONFIG_DEFAULTS, ...rest };
|
||||
}, [appConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ttsProvidersTabDisabled && isDBReady && appConfig && !sharedProvidersLoading) {
|
||||
const resetPatch: Partial<AppConfigRow> = {};
|
||||
|
||||
// When the provider tab is hidden, clear any user-set provider config back to
|
||||
// "inherit the admin default" (empty) rather than baking in a concrete value.
|
||||
if (appConfig.apiKey !== '') resetPatch.apiKey = '';
|
||||
if (appConfig.baseUrl !== '') resetPatch.baseUrl = '';
|
||||
if (appConfig.providerRef !== '') resetPatch.providerRef = '';
|
||||
if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown';
|
||||
if (appConfig.ttsModel !== '') resetPatch.ttsModel = '';
|
||||
if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = '';
|
||||
// Keep voice selection state intact so player/Audiobook voice pickers still
|
||||
// work when the TTS providers tab is hidden. This reset is only for provider
|
||||
// configuration fields.
|
||||
|
||||
if (Object.keys(resetPatch).length === 0) return;
|
||||
|
||||
updateAppConfig(resetPatch).catch((error) => {
|
||||
console.warn('Failed to clear hidden TTS provider settings:', error);
|
||||
});
|
||||
queueSyncedPreferencePatch(resetPatch);
|
||||
}
|
||||
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restrictUserApiKeys && isDBReady && appConfig && !sharedProvidersLoading) {
|
||||
const resetPatch: Partial<AppConfigRow> = {};
|
||||
|
||||
if (appConfig.apiKey !== '') resetPatch.apiKey = '';
|
||||
if (appConfig.baseUrl !== '') resetPatch.baseUrl = '';
|
||||
// Built-in providers aren't selectable in restricted mode. Clear any stale
|
||||
// built-in selection (including the old 'custom-openai' default that used to
|
||||
// be baked into every config) back to "inherit the admin default" so the
|
||||
// user follows whatever shared provider the admin has configured.
|
||||
if (isBuiltInTtsProviderId(appConfig.providerRef)) {
|
||||
resetPatch.providerRef = '';
|
||||
resetPatch.providerType = 'unknown';
|
||||
resetPatch.ttsModel = '';
|
||||
resetPatch.ttsInstructions = '';
|
||||
}
|
||||
|
||||
if (Object.keys(resetPatch).length === 0) return;
|
||||
|
||||
updateAppConfig(resetPatch).catch((error) => {
|
||||
console.warn('Failed to enforce restricted user API key mode:', error);
|
||||
});
|
||||
queueSyncedPreferencePatch(resetPatch);
|
||||
}
|
||||
}, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAllProviderModels || !isDBReady || !appConfig || sharedProvidersLoading) return;
|
||||
// Inheriting (empty providerRef): the effective model is resolved at read
|
||||
// time, so there is nothing to persist/enforce here.
|
||||
if (!appConfig.providerRef) return;
|
||||
const providerDefaults = resolveProviderDefaults({
|
||||
providerRef: appConfig.providerRef,
|
||||
providerType: appConfig.providerType,
|
||||
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 effectiveTtsModel = config.ttsModel || effectiveProvider.defaultModel;
|
||||
const effectiveTtsInstructions = config.ttsInstructions || effectiveProvider.defaultInstructions;
|
||||
const providerType = effectiveProvider.providerType;
|
||||
const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel;
|
||||
const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions;
|
||||
|
||||
const updatePatch = async (patch: Partial<AppConfigValues>) => {
|
||||
await mutation.mutateAsync(patch as SyncedPreferencesPatch);
|
||||
useEffect(() => {
|
||||
if (!isDBReady || !appConfig || sharedProvidersLoading) return;
|
||||
// Only persist a resolved providerType for an explicitly chosen provider.
|
||||
// While inheriting (empty providerRef) the type stays unset in storage.
|
||||
if (!appConfig.providerRef) return;
|
||||
if (appConfig.providerType === providerType) return;
|
||||
const patch: Partial<AppConfigRow> = { providerType };
|
||||
updateAppConfig(patch).catch((error) => {
|
||||
console.warn('Failed to persist resolved providerType:', error);
|
||||
});
|
||||
queueSyncedPreferencePatch(patch);
|
||||
}, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]);
|
||||
void _persistedProviderType;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDBReady || !appConfig || sharedProvidersLoading) return;
|
||||
// Inheriting (empty providerRef): the effective model is resolved at read
|
||||
// time; don't write a concrete model into the "inherit" state.
|
||||
if (!appConfig.providerRef) return;
|
||||
const providerDefaults = resolveProviderDefaults({
|
||||
providerRef: appConfig.providerRef,
|
||||
providerType: appConfig.providerType,
|
||||
sharedProviders,
|
||||
fallbackProviderRef: adminDefaultProviderRef,
|
||||
});
|
||||
if (!providerDefaults.defaultModel) return;
|
||||
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
|
||||
// Heal stale fallback model values that were written while the provider UI
|
||||
// was disabled and shared provider context was unavailable.
|
||||
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) return;
|
||||
|
||||
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
|
||||
updateAppConfig(patch).catch((error) => {
|
||||
console.warn('Failed to normalize shared-provider default model:', error);
|
||||
});
|
||||
queueSyncedPreferencePatch(patch);
|
||||
}, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]);
|
||||
|
||||
/**
|
||||
* Updates multiple configuration values simultaneously
|
||||
* Only saves API credentials if they are explicitly set
|
||||
*/
|
||||
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const updates: Partial<AppConfigRow> = {};
|
||||
if (newConfig.apiKey !== undefined) {
|
||||
updates.apiKey = newConfig.apiKey;
|
||||
}
|
||||
if (newConfig.baseUrl !== undefined) {
|
||||
updates.baseUrl = newConfig.baseUrl;
|
||||
}
|
||||
if (newConfig.viewType !== undefined) {
|
||||
updates.viewType = newConfig.viewType;
|
||||
}
|
||||
await updateAppConfig(updates);
|
||||
queueSyncedPreferencePatch(updates);
|
||||
} catch (error) {
|
||||
console.error('Error updating config:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 { syncPatch } = applyConfigUpdate({
|
||||
providerRef: effectiveProviderRef,
|
||||
providerType: effectiveProvider.providerType,
|
||||
ttsModel: effectiveTtsModel,
|
||||
savedVoices: config.savedVoices,
|
||||
}, key, value);
|
||||
await updatePatch(syncPatch);
|
||||
};
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const { storagePatch, syncPatch } = applyConfigUpdate({
|
||||
providerRef: effectiveProviderRef,
|
||||
providerType,
|
||||
ttsModel: effectiveTtsModel,
|
||||
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 (
|
||||
<ConfigContext.Provider value={{
|
||||
viewType: config.viewType,
|
||||
voiceSpeed: config.voiceSpeed,
|
||||
audioPlayerSpeed: config.audioPlayerSpeed,
|
||||
voice: config.voice,
|
||||
skipBlank: config.skipBlank,
|
||||
epubTheme: config.epubTheme,
|
||||
segmentPreloadDepthPages: config.segmentPreloadDepthPages,
|
||||
segmentPreloadSentenceLookahead: config.segmentPreloadSentenceLookahead,
|
||||
ttsSegmentMaxBlockLength: config.ttsSegmentMaxBlockLength,
|
||||
headerMargin: config.headerMargin,
|
||||
footerMargin: config.footerMargin,
|
||||
leftMargin: config.leftMargin,
|
||||
rightMargin: config.rightMargin,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
viewType,
|
||||
voiceSpeed,
|
||||
audioPlayerSpeed,
|
||||
voice,
|
||||
skipBlank,
|
||||
epubTheme,
|
||||
segmentPreloadDepthPages,
|
||||
segmentPreloadSentenceLookahead,
|
||||
ttsSegmentMaxBlockLength,
|
||||
headerMargin,
|
||||
footerMargin,
|
||||
leftMargin,
|
||||
rightMargin,
|
||||
providerRef: effectiveProviderRef,
|
||||
providerType: effectiveProvider.providerType,
|
||||
providerType,
|
||||
ttsModel: effectiveTtsModel,
|
||||
ttsInstructions: effectiveTtsInstructions,
|
||||
savedVoices: config.savedVoices,
|
||||
savedVoices,
|
||||
updateConfig,
|
||||
updateConfigKey,
|
||||
isLoading,
|
||||
isDBReady: !isSessionPending && !query.isPending,
|
||||
pdfHighlightEnabled: config.pdfHighlightEnabled,
|
||||
pdfWordHighlightEnabled: config.pdfWordHighlightEnabled,
|
||||
epubHighlightEnabled: config.epubHighlightEnabled,
|
||||
epubWordHighlightEnabled: config.epubWordHighlightEnabled,
|
||||
htmlHighlightEnabled: config.htmlHighlightEnabled,
|
||||
htmlWordHighlightEnabled: config.htmlWordHighlightEnabled,
|
||||
isDBReady,
|
||||
pdfHighlightEnabled,
|
||||
pdfWordHighlightEnabled,
|
||||
epubHighlightEnabled,
|
||||
epubWordHighlightEnabled,
|
||||
htmlHighlightEnabled,
|
||||
htmlWordHighlightEnabled,
|
||||
}}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to consume the configuration context
|
||||
* @returns {ConfigContextType} The configuration context value
|
||||
* @throws {Error} When used outside of ConfigProvider
|
||||
*/
|
||||
export function useConfig() {
|
||||
const context = useContext(ConfigContext);
|
||||
if (!context) 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
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 {
|
||||
deleteDocuments as deleteServerDocuments,
|
||||
|
|
@ -102,31 +102,10 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
|||
return { pdfDocs, epubDocs, htmlDocs };
|
||||
}, [docs]);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (files: File[]) => uploadServerDocuments(files),
|
||||
onSuccess: (stored) => {
|
||||
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous) =>
|
||||
mergeStoredDocuments(previous, stored),
|
||||
);
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: documentsQueryKey }),
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteServerDocuments({ ids: [id] }),
|
||||
onMutate: async (id) => {
|
||||
await queryClient.cancelQueries({ queryKey: documentsQueryKey });
|
||||
const previous = queryClient.getQueryData<SupportedDocument[]>(documentsQueryKey);
|
||||
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (rows = []) => rows.filter((doc) => doc.id !== id));
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _id, context) => queryClient.setQueryData(documentsQueryKey, context?.previous),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: documentsQueryKey }),
|
||||
});
|
||||
|
||||
const uploadDocuments = useCallback(async (files: File[]): Promise<BaseDocument[]> => {
|
||||
if (files.length === 0) return [];
|
||||
|
||||
const stored = await uploadMutation.mutateAsync(files);
|
||||
const stored = await uploadServerDocuments(files);
|
||||
await Promise.allSettled(
|
||||
stored.map(async (document, 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;
|
||||
}, [uploadMutation]);
|
||||
}, [documentsQueryKey, queryClient]);
|
||||
|
||||
const deleteDocument = useCallback(async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
await deleteServerDocuments({ ids: [id] });
|
||||
await evictCachedDocument(id);
|
||||
}, [deleteMutation]);
|
||||
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous = []) =>
|
||||
previous.filter((document) => document.id !== id),
|
||||
);
|
||||
}, [documentsQueryKey, queryClient]);
|
||||
|
||||
return (
|
||||
<DocumentContext.Provider value={{
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal';
|
||||
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
|
||||
import { PrivacyModal } from '@/components/PrivacyModal';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
||||
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie';
|
||||
import { listDocuments } from '@/lib/client/api/documents';
|
||||
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
|
||||
import { scheduleChangelogCheck } from '@/lib/client/changelog-check';
|
||||
import { createCoalescedAsyncRunner, resolveNextOnboardingStep } from '@/lib/client/onboarding-flow';
|
||||
import { useOnboardingState } from '@/hooks/useOnboardingState';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/client/query-keys';
|
||||
import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
|
||||
|
||||
type OnboardingFlowContextValue = {
|
||||
changelogOpenSignal: number;
|
||||
|
|
@ -18,14 +19,18 @@ type OnboardingFlowContextValue = {
|
|||
|
||||
const OnboardingFlowContext = createContext<OnboardingFlowContextValue | null>(null);
|
||||
|
||||
type MigrationPromptState = {
|
||||
shouldPrompt: boolean;
|
||||
localCount: number;
|
||||
missingCount: number;
|
||||
};
|
||||
|
||||
const EMPTY_CLAIM_COUNTS: ClaimableCounts = {
|
||||
documents: 0,
|
||||
audiobooks: 0,
|
||||
preferences: 0,
|
||||
progress: 0,
|
||||
documentSettings: 0,
|
||||
folders: 0,
|
||||
onboarding: 0,
|
||||
};
|
||||
|
||||
function toClaimableCounts(value: unknown): ClaimableCounts {
|
||||
|
|
@ -36,8 +41,6 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
|
|||
preferences: Number(rec.preferences ?? 0),
|
||||
progress: Number(rec.progress ?? 0),
|
||||
documentSettings: Number(rec.documentSettings ?? 0),
|
||||
folders: Number(rec.folders ?? 0),
|
||||
onboarding: Number(rec.onboarding ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -50,22 +53,71 @@ async function fetchClaimableCounts(): Promise<ClaimableCounts> {
|
|||
return toClaimableCounts(data);
|
||||
}
|
||||
|
||||
async function getMigrationPromptState(privacyGateSatisfied: boolean): Promise<MigrationPromptState> {
|
||||
const cfg = await getAppConfig();
|
||||
if (!privacyGateSatisfied || cfg?.documentsMigrationPrompted) {
|
||||
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
|
||||
}
|
||||
|
||||
const [pdfs, epubs, htmls] = await Promise.all([
|
||||
getAllPdfDocuments(),
|
||||
getAllEpubDocuments(),
|
||||
getAllHtmlDocuments(),
|
||||
]);
|
||||
const localDocs = [
|
||||
...pdfs.map((d) => d.id),
|
||||
...epubs.map((d) => d.id),
|
||||
...htmls.map((d) => d.id),
|
||||
];
|
||||
const localCount = localDocs.length;
|
||||
if (localCount === 0) {
|
||||
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
|
||||
}
|
||||
|
||||
const serverDocs = await listDocuments().catch(() => null);
|
||||
if (!serverDocs) {
|
||||
return { shouldPrompt: true, localCount, missingCount: localCount };
|
||||
}
|
||||
|
||||
const serverIds = new Set(serverDocs.map((d) => d.id));
|
||||
const missingCount = localDocs.filter((id) => !serverIds.has(id)).length;
|
||||
return {
|
||||
shouldPrompt: missingCount > 0,
|
||||
localCount,
|
||||
missingCount,
|
||||
};
|
||||
}
|
||||
|
||||
type LocalOnboardingSnapshot = {
|
||||
privacyAccepted: boolean;
|
||||
firstVisitSettingsOpened: boolean;
|
||||
};
|
||||
|
||||
async function readLocalOnboardingSnapshot(): Promise<LocalOnboardingSnapshot> {
|
||||
const appConfig = await getAppConfig();
|
||||
const row = appConfig as Record<string, unknown> | null;
|
||||
const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey;
|
||||
const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey;
|
||||
|
||||
return {
|
||||
privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false,
|
||||
firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : false,
|
||||
};
|
||||
}
|
||||
|
||||
export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||
const { data: session, isPending: isSessionPending } = useAuthSession();
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined;
|
||||
const userId = user?.id ?? null;
|
||||
const isAnonymous = Boolean(user?.isAnonymous);
|
||||
const claimCountsQuery = useQuery({
|
||||
queryKey: queryKeys.claimCounts(userId ?? 'no-session'),
|
||||
queryFn: fetchClaimableCounts,
|
||||
enabled: Boolean(userId && !isAnonymous),
|
||||
});
|
||||
const refetchClaimCounts = claimCountsQuery.refetch;
|
||||
|
||||
const { query: onboardingQuery } = useOnboardingState();
|
||||
const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | null>(null);
|
||||
const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | 'migration' | null>(null);
|
||||
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>(EMPTY_CLAIM_COUNTS);
|
||||
const [migrationCounts, setMigrationCounts] = useState<{ localCount: number; missingCount: number }>({
|
||||
localCount: 0,
|
||||
missingCount: 0,
|
||||
});
|
||||
const [changelogOpenSignal, setChangelogOpenSignal] = useState(0);
|
||||
|
||||
const pendingChangelogOpenRef = useRef(false);
|
||||
|
|
@ -83,17 +135,9 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
|||
);
|
||||
|
||||
const runOnceFlow = useCallback(async () => {
|
||||
// Wait until the onboarding state has actually loaded before deciding whether
|
||||
// to show the privacy modal. Otherwise the not-yet-loaded query (data === undefined)
|
||||
// reads as "not accepted", the modal flashes on first paint, then closes once the
|
||||
// real state arrives.
|
||||
const onboardingData = onboardingQuery.data;
|
||||
if (onboardingData === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const local = await readLocalOnboardingSnapshot();
|
||||
const privacyRequired = true;
|
||||
const privacyAccepted = !privacyRequired || Boolean(onboardingData.privacyAcceptedAtMs);
|
||||
const privacyAccepted = !privacyRequired || local.privacyAccepted;
|
||||
|
||||
const isClaimEligible = Boolean(
|
||||
userId
|
||||
|
|
@ -105,25 +149,26 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
|||
let claimHasData = false;
|
||||
|
||||
if (isClaimEligible) {
|
||||
claimCounts = (await refetchClaimCounts()).data ?? EMPTY_CLAIM_COUNTS;
|
||||
claimCounts = await fetchClaimableCounts();
|
||||
const total = claimCounts.documents
|
||||
+ claimCounts.audiobooks
|
||||
+ claimCounts.preferences
|
||||
+ claimCounts.progress
|
||||
+ claimCounts.documentSettings
|
||||
+ claimCounts.folders
|
||||
+ claimCounts.onboarding;
|
||||
+ claimCounts.documentSettings;
|
||||
claimHasData = total > 0;
|
||||
if (!claimHasData && userId) {
|
||||
claimDismissedUsersRef.current.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
const migrationState = await getMigrationPromptState(privacyAccepted);
|
||||
|
||||
const nextStep = resolveNextOnboardingStep({
|
||||
privacyRequired,
|
||||
privacyAccepted,
|
||||
claimEligible: isClaimEligible,
|
||||
claimHasData,
|
||||
migrationRequired: migrationState.shouldPrompt,
|
||||
changelogPending: pendingChangelogOpenRef.current,
|
||||
});
|
||||
|
||||
|
|
@ -138,13 +183,26 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (nextStep === 'migration') {
|
||||
setMigrationCounts({
|
||||
localCount: migrationState.localCount,
|
||||
missingCount: migrationState.missingCount,
|
||||
});
|
||||
setActiveBlockingModal('migration');
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveBlockingModal(null);
|
||||
|
||||
if (!local.firstVisitSettingsOpened) {
|
||||
await setFirstVisit(true);
|
||||
}
|
||||
|
||||
if (nextStep === 'changelog') {
|
||||
pendingChangelogOpenRef.current = false;
|
||||
setChangelogOpenSignal((value) => value + 1);
|
||||
}
|
||||
}, [isAnonymous, onboardingQuery.data, refetchClaimCounts, userId]);
|
||||
}, [isAnonymous, userId]);
|
||||
|
||||
runOnceFlowRef.current = runOnceFlow;
|
||||
|
||||
|
|
@ -156,6 +214,11 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
|||
void runFlow();
|
||||
}, [runFlow, userId]);
|
||||
|
||||
const handleMigrationComplete = useCallback(() => {
|
||||
setActiveBlockingModal(null);
|
||||
void runFlow();
|
||||
}, [runFlow]);
|
||||
|
||||
const handlePrivacyAccepted = useCallback(() => {
|
||||
setActiveBlockingModal(null);
|
||||
void runFlow();
|
||||
|
|
@ -163,7 +226,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
useEffect(() => {
|
||||
void runFlow();
|
||||
}, [isAnonymous, onboardingQuery.data, runFlow, userId]);
|
||||
}, [isAnonymous, runFlow, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPrivacyAccepted = () => {
|
||||
|
|
@ -210,6 +273,12 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
|||
onDismiss={handleClaimComplete}
|
||||
onClaimed={handleClaimComplete}
|
||||
/>
|
||||
<DexieMigrationModal
|
||||
isOpen={activeBlockingModal === 'migration'}
|
||||
localCount={migrationCounts.localCount}
|
||||
missingCount={migrationCounts.missingCount}
|
||||
onComplete={handleMigrationComplete}
|
||||
/>
|
||||
</OnboardingFlowContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function useFeatureFlag<K extends keyof RuntimeConfig>(key: K): RuntimeCo
|
|||
|
||||
/**
|
||||
* Synchronous accessor for modules that are loaded before the React tree
|
||||
* mounts (for example config defaults). Falls back to the
|
||||
* mounts (e.g. Dexie initialization, config defaults). Falls back to the
|
||||
* built-in defaults during SSR.
|
||||
*/
|
||||
export function readRuntimeConfigSync(): RuntimeConfig {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ import { useConfig } from '@/contexts/ConfigContext';
|
|||
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
||||
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
||||
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 { preprocessSentenceForAudio } from '@/lib/shared/nlp';
|
||||
import {
|
||||
|
|
@ -81,7 +82,6 @@ import type {
|
|||
TTSSegmentManifestItem,
|
||||
} from '@/types/client';
|
||||
import { isStableEpubLocator } from '@/types/client';
|
||||
import { clearCachedAudioObjectUrls, getCachedAudioUrl } from '@/lib/client/cache/audio';
|
||||
|
||||
/**
|
||||
* Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub',
|
||||
|
|
@ -420,6 +420,8 @@ const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
|||
export function TTSProvider({ children }: { children: ReactNode }): ReactElement {
|
||||
// Configuration context consumption
|
||||
const {
|
||||
apiKey: openApiKey,
|
||||
baseUrl: openApiBaseUrl,
|
||||
isLoading: configIsLoading,
|
||||
voiceSpeed,
|
||||
audioPlayerSpeed,
|
||||
|
|
@ -442,6 +444,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
// Audio and voice management hooks
|
||||
const audioContext = useAudioContext();
|
||||
const { availableVoices, fetchVoices } = useVoiceManagement(
|
||||
openApiKey,
|
||||
openApiBaseUrl,
|
||||
configProviderRef,
|
||||
configProviderType,
|
||||
configTTSModel,
|
||||
|
|
@ -462,7 +466,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
if (Array.isArray(id)) return id[0];
|
||||
return '';
|
||||
}, [id]);
|
||||
const { query: progressQuery, schedule: scheduleProgress } = useDocumentProgress(documentId || undefined);
|
||||
|
||||
const currentReaderType: ReaderType = useMemo(() => {
|
||||
if (pathname.startsWith('/epub/')) return 'epub';
|
||||
|
|
@ -726,7 +729,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
useEffect(() => () => {
|
||||
clearWarmAudioCache();
|
||||
clearCachedAudioObjectUrls();
|
||||
}, [clearWarmAudioCache]);
|
||||
|
||||
const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => {
|
||||
|
|
@ -1617,7 +1619,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setTTSModel(configTTSModel);
|
||||
setTTSInstructions(configTTSInstructions);
|
||||
}
|
||||
}, [configIsLoading, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
|
||||
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
|
||||
|
||||
const preloadGenerationSignatureRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
|
|
@ -1764,8 +1766,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': openApiKey || '',
|
||||
'x-tts-provider': configProviderRef,
|
||||
};
|
||||
if (openApiBaseUrl) {
|
||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||||
}
|
||||
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
|
|
@ -1908,6 +1914,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
ttsModel,
|
||||
ttsInstructions,
|
||||
resolvedLanguage,
|
||||
openApiKey,
|
||||
openApiBaseUrl,
|
||||
configProviderRef,
|
||||
configProviderType,
|
||||
providerModelPolicy.supportsInstructions,
|
||||
|
|
@ -2036,13 +2044,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setCurrentSentenceAlignment(playbackSource.manifest.alignment);
|
||||
setCurrentWordIndex(null);
|
||||
}
|
||||
const sourceUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl;
|
||||
const audioUrl = await getCachedAudioUrl({
|
||||
audioKey: playbackSource.manifest.segmentId,
|
||||
version: playbackSource.manifest.durationMs,
|
||||
primaryUrl: sourceUrl,
|
||||
fallbackUrl: playbackSource.fallbackUrl,
|
||||
}).catch(() => sourceUrl);
|
||||
const audioUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl;
|
||||
|
||||
// Force unload any previous Howl instance to free up resources
|
||||
if (activeHowl) {
|
||||
|
|
@ -2608,8 +2610,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
activeAbortControllers.current.add(controller);
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': openApiKey || '',
|
||||
'x-tts-provider': configProviderRef,
|
||||
};
|
||||
if (openApiBaseUrl) {
|
||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||||
}
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
initialDelay: 300,
|
||||
|
|
@ -2897,8 +2903,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
activeAbortControllers.current.add(controller);
|
||||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': openApiKey || '',
|
||||
'x-tts-provider': configProviderRef,
|
||||
};
|
||||
if (openApiBaseUrl) {
|
||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||||
}
|
||||
|
||||
const retryOptions: TTSRetryOptions = {
|
||||
maxRetries: 2,
|
||||
|
|
@ -3016,6 +3026,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
configProviderRef,
|
||||
configProviderType,
|
||||
ttsModel,
|
||||
openApiKey,
|
||||
openApiBaseUrl,
|
||||
providerModelPolicy.supportsInstructions,
|
||||
ttsInstructions,
|
||||
resolvedLanguage,
|
||||
|
|
@ -3385,9 +3397,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
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(() => {
|
||||
if (!id || !progressQuery.data?.location) return;
|
||||
if (!id) return;
|
||||
|
||||
let cancelled = false;
|
||||
const docId = id as string;
|
||||
|
||||
const applyLocation = (lastLocation: string) => {
|
||||
if (isEPUB && locationChangeHandlerRef.current) {
|
||||
|
|
@ -3438,8 +3454,35 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
}
|
||||
};
|
||||
|
||||
applyLocation(progressQuery.data.location);
|
||||
}, [id, isEPUB, currentReaderType, progressQuery.data?.location]);
|
||||
const load = async () => {
|
||||
try {
|
||||
const local = await getLastDocumentLocation(docId);
|
||||
if (!cancelled && local) {
|
||||
applyLocation(local);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading local last location:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const remote = await getDocumentProgress(docId);
|
||||
if (!cancelled && remote?.location) {
|
||||
await setLastDocumentLocation(docId, remote.location).catch((error) => {
|
||||
console.warn('Error caching remote location locally:', error);
|
||||
});
|
||||
applyLocation(remote.location);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading remote progress:', error);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, isEPUB, currentReaderType]);
|
||||
|
||||
// Save current position periodically for non-EPUB readers.
|
||||
useEffect(() => {
|
||||
|
|
@ -3448,7 +3491,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
? `html:${encodeURIComponent(String(currDocPage || 1))}:${currentIndex}`
|
||||
: `${currDocPageNumber}:${currentIndex}`;
|
||||
const timeoutId = setTimeout(() => {
|
||||
scheduleProgress({
|
||||
setLastDocumentLocation(id as string, location).catch(error => {
|
||||
console.warn('Error saving non-EPUB location:', error);
|
||||
});
|
||||
scheduleDocumentProgressSync({
|
||||
documentId: id as string,
|
||||
readerType: currentReaderType,
|
||||
location,
|
||||
|
|
@ -3457,7 +3503,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@ import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'
|
|||
|
||||
/**
|
||||
* Custom hook for managing TTS voices
|
||||
* @param apiKey OpenAI API key
|
||||
* @param baseUrl OpenAI API base URL
|
||||
* @param providerRef TTS provider routing reference (built-in id or shared slug)
|
||||
* @param providerType Resolved provider type for capability/default logic
|
||||
* @param ttsModel TTS model name
|
||||
* @returns Object containing available voices and fetch function
|
||||
*/
|
||||
export function useVoiceManagement(
|
||||
apiKey: string | undefined,
|
||||
baseUrl: string | undefined,
|
||||
providerRef: string | undefined,
|
||||
providerType: TtsProviderType | undefined,
|
||||
ttsModel: string | undefined
|
||||
|
|
@ -25,6 +29,8 @@ export function useVoiceManagement(
|
|||
try {
|
||||
console.log('Fetching voices...');
|
||||
const data = await getVoices({
|
||||
'x-openai-key': apiKey || '',
|
||||
'x-openai-base-url': baseUrl || '',
|
||||
'x-tts-provider': providerRef || 'openai',
|
||||
'x-tts-model': ttsModel || 'tts-1',
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -50,7 +56,7 @@ export function useVoiceManagement(
|
|||
model: ttsModel || 'tts-1',
|
||||
}).defaultVoices);
|
||||
}
|
||||
}, [providerRef, providerType, ttsModel]);
|
||||
}, [apiKey, baseUrl, providerRef, providerType, ttsModel]);
|
||||
|
||||
return { availableVoices, fetchVoices };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export function filterNonEmptySpineTextEntries<T extends { text: string }>(entri
|
|||
type UseEpubAudiobookParams = {
|
||||
bookRef: RefObject<Book | null>;
|
||||
tocRef: RefObject<NavItem[]>;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
providerRef: string;
|
||||
};
|
||||
|
||||
|
|
@ -59,6 +61,8 @@ type UseEpubAudiobookResult = {
|
|||
export function useEPUBAudiobook({
|
||||
bookRef,
|
||||
tocRef,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
providerRef,
|
||||
}: UseEpubAudiobookParams): UseEpubAudiobookResult {
|
||||
const loadSpineSection = useCallback(async (href: string) => {
|
||||
|
|
@ -127,6 +131,8 @@ export function useEPUBAudiobook({
|
|||
try {
|
||||
return await runAudiobookGeneration({
|
||||
adapter: audiobookAdapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: providerRef,
|
||||
onProgress,
|
||||
signal,
|
||||
|
|
@ -139,7 +145,7 @@ export function useEPUBAudiobook({
|
|||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [audiobookAdapter, providerRef]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||
|
||||
const regenerateChapter = useCallback(async (
|
||||
chapterIndex: number,
|
||||
|
|
@ -155,6 +161,8 @@ export function useEPUBAudiobook({
|
|||
bookId,
|
||||
format,
|
||||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: providerRef,
|
||||
settings,
|
||||
});
|
||||
|
|
@ -165,7 +173,7 @@ export function useEPUBAudiobook({
|
|||
console.error('Error regenerating chapter:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [audiobookAdapter, providerRef]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||
|
||||
return {
|
||||
createFullAudioBook,
|
||||
|
|
|
|||
53
src/hooks/epub/useEPUBDocuments.ts
Normal file
53
src/hooks/epub/useEPUBDocuments.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,14 +3,33 @@
|
|||
import { useCallback, type MutableRefObject, type RefObject } from 'react';
|
||||
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 {
|
||||
isDirectionalEpubLocation,
|
||||
shouldNavigateToDifferentCfi,
|
||||
shouldPersistEpubLocation,
|
||||
type EpubLocation,
|
||||
} from '@/lib/client/epub/location-controller';
|
||||
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;
|
||||
}
|
||||
|
||||
type UseEpubLocationControllerParams = {
|
||||
documentId?: string;
|
||||
|
|
@ -35,7 +54,6 @@ export function useEPUBLocationController({
|
|||
renditionRef,
|
||||
locationRef,
|
||||
}: UseEpubLocationControllerParams): (location: EpubLocation) => void {
|
||||
const { schedule: scheduleProgress } = useDocumentProgress(documentId);
|
||||
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
|
||||
const book = bookRef.current;
|
||||
const rendition = renditionRef.current;
|
||||
|
|
@ -115,9 +133,10 @@ export function useEPUBLocationController({
|
|||
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)) {
|
||||
scheduleProgress({
|
||||
setLastDocumentLocation(documentId, location.toString());
|
||||
scheduleDocumentProgressSync({
|
||||
documentId,
|
||||
readerType: 'epub',
|
||||
location: location.toString(),
|
||||
|
|
@ -141,11 +160,8 @@ export function useEPUBLocationController({
|
|||
safeRenditionNavigate,
|
||||
setIsEpub,
|
||||
shouldPauseRef,
|
||||
scheduleProgress,
|
||||
skipToLocation,
|
||||
]);
|
||||
|
||||
return handleLocationChanged;
|
||||
}
|
||||
|
||||
export { isDirectionalEpubLocation, shouldNavigateToDifferentCfi, shouldPersistEpubLocation };
|
||||
|
|
|
|||
52
src/hooks/html/useHTMLDocuments.ts
Normal file
52
src/hooks/html/useHTMLDocuments.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
50
src/hooks/pdf/usePDFDocuments.ts
Normal file
50
src/hooks/pdf/usePDFDocuments.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,31 +1,52 @@
|
|||
'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 { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
|
||||
import { useDocumentSettings } from '@/hooks/useDocumentSettings';
|
||||
|
||||
export function useDocumentLanguage(documentId: string | undefined): {
|
||||
language: string;
|
||||
updateLanguage: (language: string) => Promise<void>;
|
||||
} {
|
||||
const { query, mutation } = useDocumentSettings(documentId);
|
||||
const settings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, query.data?.settings);
|
||||
const [settings, setSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
|
||||
|
||||
useEffect(() => {
|
||||
setSettings(DEFAULT_DOCUMENT_SETTINGS);
|
||||
if (!documentId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
void getDocumentSettings(documentId, { signal: controller.signal })
|
||||
.then((response) => {
|
||||
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
console.warn('Failed to load document language, using automatic detection:', error);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [documentId]);
|
||||
|
||||
const updateLanguage = useCallback(async (language: string): Promise<void> => {
|
||||
if (!documentId) return;
|
||||
const next: DocumentSettings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
|
||||
...settings,
|
||||
schemaVersion: 1,
|
||||
language,
|
||||
let next = DEFAULT_DOCUMENT_SETTINGS;
|
||||
setSettings((prev) => {
|
||||
next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
|
||||
...prev,
|
||||
schemaVersion: 1,
|
||||
language,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await mutation.mutateAsync(next);
|
||||
const response = await putDocumentSettings(documentId, next);
|
||||
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist document language:', error);
|
||||
}
|
||||
}, [documentId, mutation, settings]);
|
||||
}, [documentId]);
|
||||
|
||||
return {
|
||||
language: settings.language ?? 'auto',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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> {
|
||||
const docs = await listDocuments({ ids: [id], signal: options?.signal });
|
||||
const document = docs[0] ?? null;
|
||||
if (document) {
|
||||
void fetch(`/api/documents/${encodeURIComponent(id)}/opened`, { method: 'PUT' }).catch(() => {});
|
||||
}
|
||||
return document;
|
||||
return docs[0] ?? null;
|
||||
}
|
||||
|
||||
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> {
|
||||
return (await fetchDocumentContentResponse(id, options)).arrayBuffer();
|
||||
}
|
||||
|
||||
export async function fetchDocumentContentResponse(id: string, options?: { signal?: AbortSignal }): Promise<Response> {
|
||||
const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(id)}`;
|
||||
|
||||
const fetchFallback = async (): Promise<Response> => {
|
||||
const fetchFallback = async (): Promise<ArrayBuffer> => {
|
||||
const res = await fetch(fallbackUrl, { signal: options?.signal });
|
||||
if (!res.ok) {
|
||||
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})`);
|
||||
}
|
||||
return res;
|
||||
return res.arrayBuffer();
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -475,7 +467,7 @@ export async function fetchDocumentContentResponse(id: string, options?: { signa
|
|||
}
|
||||
throw new Error(`Failed to download document (status ${directRes.status})`);
|
||||
}
|
||||
return directRes;
|
||||
return directRes.arrayBuffer();
|
||||
} catch (error) {
|
||||
if (options?.signal?.aborted) throw error;
|
||||
return fetchFallback();
|
||||
|
|
@ -516,7 +508,6 @@ export type DocumentPreviewReady = {
|
|||
fallbackUrl: string;
|
||||
presignUrl: string;
|
||||
directUrl?: string;
|
||||
previewVersion: string;
|
||||
};
|
||||
|
||||
export type DocumentPreviewStatus = DocumentPreviewPending | DocumentPreviewReady;
|
||||
|
|
@ -565,14 +556,12 @@ export async function getDocumentPreviewStatus(
|
|||
fallbackUrl?: string;
|
||||
presignUrl?: string;
|
||||
directUrl?: string;
|
||||
previewVersion?: string;
|
||||
} | null;
|
||||
return {
|
||||
kind: 'ready',
|
||||
fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id),
|
||||
presignUrl: data?.presignUrl || documentPreviewPresignUrl(id),
|
||||
directUrl: data?.directUrl,
|
||||
previewVersion: data?.previewVersion || '',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -617,3 +606,4 @@ export async function importUrl(
|
|||
|
||||
return (await res.json()) as { title: string; content: string };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,102 @@ export async function postChangelogVersionCheck(
|
|||
return (await res.json()) as ChangelogVersionCheckResponse;
|
||||
}
|
||||
|
||||
type PendingPreferenceSync = {
|
||||
patch: SyncedPreferencesPatch;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
const pendingPreferenceSync: PendingPreferenceSync = {
|
||||
patch: {},
|
||||
timer: null,
|
||||
sessionId: null,
|
||||
};
|
||||
|
||||
let activeSyncController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Cancel any pending debounced preference sync and abort in-flight requests.
|
||||
* Call this on session change / sign-out to prevent cross-account writes.
|
||||
*/
|
||||
export function cancelPendingPreferenceSync(): void {
|
||||
if (pendingPreferenceSync.timer) {
|
||||
clearTimeout(pendingPreferenceSync.timer);
|
||||
pendingPreferenceSync.timer = null;
|
||||
}
|
||||
pendingPreferenceSync.patch = {};
|
||||
pendingPreferenceSync.sessionId = null;
|
||||
|
||||
if (activeSyncController) {
|
||||
activeSyncController.abort();
|
||||
activeSyncController = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleUserPreferencesSync(
|
||||
patch: SyncedPreferencesPatch,
|
||||
sessionId: string,
|
||||
debounceMs: number = 600,
|
||||
): void {
|
||||
Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch));
|
||||
pendingPreferenceSync.sessionId = sessionId;
|
||||
|
||||
if (pendingPreferenceSync.timer) {
|
||||
clearTimeout(pendingPreferenceSync.timer);
|
||||
}
|
||||
|
||||
const capturedSessionId = sessionId;
|
||||
|
||||
pendingPreferenceSync.timer = setTimeout(async () => {
|
||||
// If the session changed between scheduling and firing, discard.
|
||||
if (pendingPreferenceSync.sessionId !== capturedSessionId) return;
|
||||
|
||||
const payload = { ...pendingPreferenceSync.patch };
|
||||
pendingPreferenceSync.patch = {};
|
||||
pendingPreferenceSync.timer = null;
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
|
||||
// Abort any previous in-flight sync and create a fresh controller.
|
||||
if (activeSyncController) activeSyncController.abort();
|
||||
activeSyncController = new AbortController();
|
||||
|
||||
try {
|
||||
await putUserPreferences(payload, { signal: activeSyncController.signal });
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') return;
|
||||
console.warn('Failed to sync user preferences:', error);
|
||||
} finally {
|
||||
activeSyncController = null;
|
||||
}
|
||||
}, debounceMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately send any pending debounced preference patch and await the result.
|
||||
* Use this when the user explicitly saves: we want the server write to complete
|
||||
* (and surface failures) instead of silently relying on the debounce timer, which
|
||||
* can be lost if the page is reloaded inside the debounce window.
|
||||
*/
|
||||
export async function flushUserPreferencesSync(): Promise<void> {
|
||||
if (pendingPreferenceSync.timer) {
|
||||
clearTimeout(pendingPreferenceSync.timer);
|
||||
pendingPreferenceSync.timer = null;
|
||||
}
|
||||
|
||||
const payload = { ...pendingPreferenceSync.patch };
|
||||
pendingPreferenceSync.patch = {};
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
|
||||
if (activeSyncController) activeSyncController.abort();
|
||||
activeSyncController = new AbortController();
|
||||
|
||||
try {
|
||||
await putUserPreferences(payload, { signal: activeSyncController.signal });
|
||||
} finally {
|
||||
activeSyncController = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDocumentProgress(
|
||||
documentId: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
|
|
@ -120,3 +216,56 @@ export async function putDocumentProgress(payload: {
|
|||
const data = (await res.json()) as ProgressResponse;
|
||||
return data.progress ?? null;
|
||||
}
|
||||
|
||||
type PendingProgressSync = {
|
||||
payload: {
|
||||
documentId: string;
|
||||
readerType: ReaderType;
|
||||
location: string;
|
||||
progress: number | null;
|
||||
};
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
const pendingProgressByDoc = new Map<string, PendingProgressSync>();
|
||||
|
||||
export function scheduleDocumentProgressSync(
|
||||
payload: {
|
||||
documentId: string;
|
||||
readerType: ReaderType;
|
||||
location: string;
|
||||
progress?: number | null;
|
||||
},
|
||||
debounceMs: number = 1000,
|
||||
): void {
|
||||
const existing = pendingProgressByDoc.get(payload.documentId);
|
||||
if (existing?.timer) {
|
||||
clearTimeout(existing.timer);
|
||||
}
|
||||
|
||||
const next: PendingProgressSync = {
|
||||
payload: {
|
||||
documentId: payload.documentId,
|
||||
readerType: payload.readerType,
|
||||
location: payload.location,
|
||||
progress: payload.progress ?? null,
|
||||
},
|
||||
timer: null,
|
||||
};
|
||||
|
||||
next.timer = setTimeout(async () => {
|
||||
pendingProgressByDoc.delete(payload.documentId);
|
||||
try {
|
||||
await putDocumentProgress({
|
||||
documentId: next.payload.documentId,
|
||||
readerType: next.payload.readerType,
|
||||
location: next.payload.location,
|
||||
progress: next.payload.progress,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync document progress:', error);
|
||||
}
|
||||
}, debounceMs);
|
||||
|
||||
pendingProgressByDoc.set(payload.documentId, next);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export interface AudiobookSourceAdapter {
|
|||
|
||||
interface RunAudiobookGenerationOptions {
|
||||
adapter: AudiobookSourceAdapter;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
defaultProvider: string;
|
||||
onProgress: (progress: number) => void;
|
||||
signal?: AbortSignal;
|
||||
|
|
@ -45,6 +47,8 @@ interface RegenerateAudiobookChapterOptions {
|
|||
bookId: string;
|
||||
format: TTSAudiobookFormat;
|
||||
signal: AbortSignal;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
defaultProvider: string;
|
||||
settings?: AudiobookGenerationSettings;
|
||||
retryOptions?: TTSRetryOptions;
|
||||
|
|
@ -67,10 +71,14 @@ function resolveAudiobookRequestSettings(
|
|||
}
|
||||
|
||||
function buildAudiobookRequestHeaders(
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
effectiveProvider: string,
|
||||
): TTSRequestHeaders {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
'x-tts-provider': effectiveProvider,
|
||||
};
|
||||
}
|
||||
|
|
@ -87,6 +95,8 @@ function createAudiobookAbortError(): Error {
|
|||
|
||||
export async function runAudiobookGeneration({
|
||||
adapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider,
|
||||
onProgress,
|
||||
signal,
|
||||
|
|
@ -110,7 +120,7 @@ export async function runAudiobookGeneration({
|
|||
}
|
||||
|
||||
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||
const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef);
|
||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
|
||||
let processedLength = 0;
|
||||
let bookId = providedBookId;
|
||||
|
||||
|
|
@ -211,6 +221,8 @@ export async function regenerateAudiobookChapter({
|
|||
bookId,
|
||||
format,
|
||||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider,
|
||||
settings,
|
||||
retryOptions = {
|
||||
|
|
@ -226,7 +238,7 @@ export async function regenerateAudiobookChapter({
|
|||
}
|
||||
|
||||
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||
const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef);
|
||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
|
||||
|
||||
return withRetry(
|
||||
async () => {
|
||||
|
|
|
|||
28
src/lib/client/cache/audio.ts
vendored
28
src/lib/client/cache/audio.ts
vendored
|
|
@ -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();
|
||||
}
|
||||
60
src/lib/client/cache/blob-cache.ts
vendored
60
src/lib/client/cache/blob-cache.ts
vendored
|
|
@ -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))}`;
|
||||
}
|
||||
170
src/lib/client/cache/documents.ts
vendored
170
src/lib/client/cache/documents.ts
vendored
|
|
@ -1,50 +1,152 @@
|
|||
import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '@/types/documents';
|
||||
import { fetchDocumentContentResponse } from '@/lib/client/api/documents';
|
||||
import {
|
||||
clearBlobCache,
|
||||
documentBlobCacheKey,
|
||||
evictCachedBlobPrefix,
|
||||
getCachedBlob,
|
||||
putCachedBlob,
|
||||
} from '@/lib/client/cache/blob-cache';
|
||||
import { downloadDocumentContent } from '@/lib/client/api/documents';
|
||||
|
||||
function stableKey(meta: BaseDocument): string {
|
||||
return documentBlobCacheKey(meta.id, meta.contentVersion || meta.id);
|
||||
}
|
||||
export type DocumentCacheBackend = {
|
||||
get: (meta: BaseDocument) => Promise<PDFDocument | EPUBDocument | HTMLDocument | null>;
|
||||
putPdf: (meta: BaseDocument, data: ArrayBuffer) => Promise<void>;
|
||||
putEpub: (meta: BaseDocument, data: ArrayBuffer) => Promise<void>;
|
||||
putHtml: (meta: BaseDocument, data: string) => Promise<void>;
|
||||
download: (id: string, options?: { signal?: AbortSignal }) => Promise<ArrayBuffer>;
|
||||
decodeText: (buffer: ArrayBuffer) => string;
|
||||
};
|
||||
|
||||
async function readDocument(meta: BaseDocument, response: Response): Promise<PDFDocument | EPUBDocument | HTMLDocument> {
|
||||
if (meta.type === 'html') {
|
||||
return { ...meta, type: 'html', data: await response.text() };
|
||||
}
|
||||
const data = await response.arrayBuffer();
|
||||
if (meta.type === 'epub') return { ...meta, type: 'epub', data };
|
||||
return { ...meta, type: 'pdf', data };
|
||||
}
|
||||
|
||||
export async function ensureCachedDocument(
|
||||
export async function ensureCachedDocumentCore(
|
||||
meta: BaseDocument,
|
||||
backend: DocumentCacheBackend,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<PDFDocument | EPUBDocument | HTMLDocument> {
|
||||
if (meta.type !== 'pdf' && meta.type !== 'epub' && meta.type !== 'html') {
|
||||
throw new Error(`Unsupported cached document type: ${meta.type}`);
|
||||
const cached = await backend.get(meta);
|
||||
if (cached) return cached;
|
||||
|
||||
const buffer = await backend.download(meta.id, { signal: options?.signal });
|
||||
|
||||
if (meta.type === 'pdf') {
|
||||
await backend.putPdf(meta, buffer);
|
||||
const after = await backend.get(meta);
|
||||
if (!after || after.type !== 'pdf') throw new Error('Failed to cache PDF');
|
||||
return after;
|
||||
}
|
||||
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> {
|
||||
const type = stored.type === 'pdf'
|
||||
? 'application/pdf'
|
||||
: stored.type === 'epub'
|
||||
? 'application/epub+zip'
|
||||
: 'text/plain; charset=utf-8';
|
||||
await putCachedBlob(stableKey(stored), new Response(bytes, { status: 200, headers: { 'Content-Type': type } }));
|
||||
export async function getCachedPdf(id: string): Promise<PDFDocument | null> {
|
||||
const { getPdfDocument } = await import('@/lib/client/dexie');
|
||||
return (await getPdfDocument(id)) ?? null;
|
||||
}
|
||||
|
||||
export async function putCachedPdf(meta: BaseDocument, data: ArrayBuffer): Promise<void> {
|
||||
const { addPdfDocument } = await import('@/lib/client/dexie');
|
||||
await addPdfDocument({
|
||||
id: meta.id,
|
||||
type: 'pdf',
|
||||
name: meta.name,
|
||||
size: meta.size,
|
||||
lastModified: meta.lastModified,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function evictCachedPdf(id: string): Promise<void> {
|
||||
const { removePdfDocument } = await import('@/lib/client/dexie');
|
||||
await removePdfDocument(id);
|
||||
}
|
||||
|
||||
export async function getCachedEpub(id: string): Promise<EPUBDocument | null> {
|
||||
const { getEpubDocument } = await import('@/lib/client/dexie');
|
||||
return (await getEpubDocument(id)) ?? null;
|
||||
}
|
||||
|
||||
export async function putCachedEpub(meta: BaseDocument, data: ArrayBuffer): Promise<void> {
|
||||
const { addEpubDocument } = await import('@/lib/client/dexie');
|
||||
await addEpubDocument({
|
||||
id: meta.id,
|
||||
type: 'epub',
|
||||
name: meta.name,
|
||||
size: meta.size,
|
||||
lastModified: meta.lastModified,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function evictCachedEpub(id: string): Promise<void> {
|
||||
const { removeEpubDocument } = await import('@/lib/client/dexie');
|
||||
await removeEpubDocument(id);
|
||||
}
|
||||
|
||||
export async function getCachedHtml(id: string): Promise<HTMLDocument | null> {
|
||||
const { getHtmlDocument } = await import('@/lib/client/dexie');
|
||||
return (await getHtmlDocument(id)) ?? null;
|
||||
}
|
||||
|
||||
export async function putCachedHtml(meta: BaseDocument, data: string): Promise<void> {
|
||||
const { addHtmlDocument } = await import('@/lib/client/dexie');
|
||||
await addHtmlDocument({
|
||||
id: meta.id,
|
||||
type: 'html',
|
||||
name: meta.name,
|
||||
size: meta.size,
|
||||
lastModified: meta.lastModified,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function evictCachedHtml(id: string): Promise<void> {
|
||||
const { removeHtmlDocument } = await import('@/lib/client/dexie');
|
||||
await removeHtmlDocument(id);
|
||||
}
|
||||
|
||||
export async function evictCachedDocument(id: string): Promise<void> {
|
||||
await evictCachedBlobPrefix(`/openreader-cache/documents/${encodeURIComponent(id)}/`);
|
||||
await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
144
src/lib/client/cache/previews.ts
vendored
144
src/lib/client/cache/previews.ts
vendored
|
|
@ -1,11 +1,24 @@
|
|||
import {
|
||||
clearDocumentPreviewCache as clearPersistedDocumentPreviewCache,
|
||||
getDocumentPreviewCache,
|
||||
putDocumentPreviewCache,
|
||||
removeDocumentPreviewCache,
|
||||
} from '@/lib/client/dexie';
|
||||
import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/client/api/documents';
|
||||
import { clearBlobCache, getCachedBlob, previewBlobCacheKey } from '@/lib/client/cache/blob-cache';
|
||||
|
||||
const inMemoryPreviewUrlCache = new Map<string, string>();
|
||||
const inFlightPreviewPrime = new Map<string, Promise<string | null>>();
|
||||
const inFlightPersistedPreviewUrl = new Map<string, Promise<string | null>>();
|
||||
const PREVIEW_CACHE_SCHEMA_VERSION = 4;
|
||||
|
||||
function revokeIfBlobUrl(url: string | null | undefined): void {
|
||||
if (url?.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 {
|
||||
|
|
@ -14,62 +27,131 @@ export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null {
|
|||
|
||||
export function setInMemoryDocumentPreviewUrl(cacheKey: string, url: string): void {
|
||||
const prev = inMemoryPreviewUrlCache.get(cacheKey);
|
||||
if (prev && prev !== url) revokeIfBlobUrl(prev);
|
||||
if (prev && prev !== url) {
|
||||
revokeIfBlobUrl(prev);
|
||||
}
|
||||
inMemoryPreviewUrlCache.set(cacheKey, url);
|
||||
}
|
||||
|
||||
export function clearInMemoryDocumentPreviewCache(): void {
|
||||
for (const value of inMemoryPreviewUrlCache.values()) revokeIfBlobUrl(value);
|
||||
for (const value of inMemoryPreviewUrlCache.values()) {
|
||||
revokeIfBlobUrl(value);
|
||||
}
|
||||
inMemoryPreviewUrlCache.clear();
|
||||
}
|
||||
|
||||
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);
|
||||
inFlightPersistedPreviewUrl.clear();
|
||||
}
|
||||
|
||||
export async function getPersistedDocumentPreviewUrl(
|
||||
docId: string,
|
||||
previewVersion: string | number,
|
||||
lastModified: number,
|
||||
cacheKey: string,
|
||||
): 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(
|
||||
docId: string,
|
||||
previewVersion: string | number,
|
||||
lastModified: number,
|
||||
cacheKey: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<string | null> {
|
||||
const memory = getInMemoryDocumentPreviewUrl(cacheKey);
|
||||
if (memory) return memory;
|
||||
const primeKey = `${docId}:${previewVersion}`;
|
||||
const existing = inFlightPreviewPrime.get(primeKey);
|
||||
const primeKey = `${cacheKey}:${Number(lastModified)}`;
|
||||
const existingPrime = inFlightPreviewPrime.get(primeKey);
|
||||
if (existingPrime) {
|
||||
return existingPrime;
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
const existing = await getPersistedDocumentPreviewUrl(docId, lastModified, cacheKey);
|
||||
if (existing) return existing;
|
||||
const promise = (async () => {
|
||||
const response = await getCachedBlob(
|
||||
previewBlobCacheKey(docId, previewVersion),
|
||||
() => fetchPreviewSource(docId, options?.signal),
|
||||
).catch(() => null);
|
||||
if (!response?.ok) return null;
|
||||
const blob = await response.blob();
|
||||
if (blob.size === 0) return null;
|
||||
const url = URL.createObjectURL(blob);
|
||||
setInMemoryDocumentPreviewUrl(cacheKey, url);
|
||||
return url;
|
||||
|
||||
const fetchOptions = {
|
||||
signal: options?.signal,
|
||||
cache: 'no-store' as const,
|
||||
};
|
||||
|
||||
// Prefer presign path for priming so healthy direct object access avoids proxy load.
|
||||
let res = await fetch(documentPreviewPresignUrl(docId), fetchOptions).catch(() => null);
|
||||
if (!res || !res.ok) {
|
||||
res = await fetch(documentPreviewFallbackUrl(docId), fetchOptions).catch(() => null);
|
||||
}
|
||||
if (!res || !res.ok) return null;
|
||||
|
||||
const blob = await res.blob();
|
||||
const bytes = await blob.arrayBuffer();
|
||||
if (bytes.byteLength === 0) return null;
|
||||
|
||||
const contentType = blob.type || 'image/jpeg';
|
||||
await putDocumentPreviewCache({
|
||||
docId,
|
||||
lastModified: Number(lastModified),
|
||||
contentType,
|
||||
data: bytes,
|
||||
cachedAt: Date.now(),
|
||||
previewVersion: PREVIEW_CACHE_SCHEMA_VERSION,
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: contentType }));
|
||||
setInMemoryDocumentPreviewUrl(cacheKey, url);
|
||||
return url;
|
||||
})();
|
||||
|
||||
inFlightPreviewPrime.set(primeKey, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
inFlightPreviewPrime.delete(primeKey);
|
||||
if (inFlightPreviewPrime.get(primeKey) === promise) {
|
||||
inFlightPreviewPrime.delete(primeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearAllDocumentPreviewCaches(): Promise<void> {
|
||||
clearInMemoryDocumentPreviewCache();
|
||||
await clearBlobCache();
|
||||
await clearPersistedDocumentPreviewCache();
|
||||
}
|
||||
|
|
|
|||
1285
src/lib/client/dexie.ts
Normal file
1285
src/lib/client/dexie.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
export type OnboardingStep = 'privacy' | 'claim' | 'changelog' | 'done';
|
||||
export type OnboardingStep = 'privacy' | 'claim' | 'migration' | 'changelog' | 'done';
|
||||
|
||||
export type OnboardingStepSnapshot = {
|
||||
privacyRequired: boolean;
|
||||
privacyAccepted: boolean;
|
||||
claimEligible: boolean;
|
||||
claimHasData: boolean;
|
||||
migrationRequired: boolean;
|
||||
changelogPending: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -17,6 +18,10 @@ export function resolveNextOnboardingStep(snapshot: OnboardingStepSnapshot): Onb
|
|||
return 'claim';
|
||||
}
|
||||
|
||||
if (snapshot.migrationRequired) {
|
||||
return 'migration';
|
||||
}
|
||||
|
||||
if (snapshot.changelogPending) {
|
||||
return 'changelog';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
export interface ResolvedTtsCredentials {
|
||||
/** Provider id passed downstream to TTS generation (one of the 4 built-in IDs). */
|
||||
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;
|
||||
/** Base URL, or undefined to fall through to provider defaults. */
|
||||
baseUrl: string | undefined;
|
||||
|
|
@ -21,26 +21,62 @@ export interface ResolvedTtsCredentials {
|
|||
/**
|
||||
* Resolve TTS credentials for an incoming request.
|
||||
*
|
||||
* Only admin-managed shared providers can supply credentials. Built-in
|
||||
* provider ids select the preferred enabled shared provider of that type.
|
||||
* 1. If `restrictUserApiKeys` is enabled, only admin shared providers are
|
||||
* used. Built-in provider ids and user-supplied key/base headers are
|
||||
* ignored.
|
||||
* 2. If `providerHeader` matches an enabled admin provider slug, use its
|
||||
* server-stored credentials. Any `x-openai-key` / `x-openai-base-url`
|
||||
* headers from the client are ignored — admin keys must never round-trip
|
||||
* through the client.
|
||||
* 3. Otherwise, treat `providerHeader` as a built-in provider id and use the
|
||||
* per-user `x-openai-key` / `x-openai-base-url` headers as today.
|
||||
*
|
||||
* Returns `null` when the request references a slug that exists but is
|
||||
* disabled — callers should reject with a 4xx.
|
||||
*/
|
||||
export async function resolveTtsCredentials(opts: {
|
||||
providerHeader: string | null;
|
||||
apiKeyHeader: string | null;
|
||||
baseUrlHeader: string | null;
|
||||
fallbackProvider?: string;
|
||||
restrictUserApiKeys?: boolean;
|
||||
}): Promise<ResolvedTtsCredentials | { error: 'provider_disabled' | 'provider_unknown' | 'no_shared_provider_configured'; slug: string }> {
|
||||
const requestedProvider = opts.providerHeader || opts.fallbackProvider || 'openai';
|
||||
|
||||
const admin = isBuiltInProviderId(requestedProvider)
|
||||
? await resolvePreferredEnabledAdminProvider({
|
||||
requestedSlug: null,
|
||||
runtimeDefaultSlug: opts.fallbackProvider || '',
|
||||
})
|
||||
: await getEnabledAdminProviderBySlug(requestedProvider);
|
||||
if (opts.restrictUserApiKeys) {
|
||||
const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider);
|
||||
const fallback = opts.fallbackProvider || '';
|
||||
const selected = await resolvePreferredEnabledAdminProvider({
|
||||
requestedSlug: requestedIsBuiltIn ? null : requestedProvider,
|
||||
runtimeDefaultSlug: fallback,
|
||||
});
|
||||
if (!selected) {
|
||||
return { error: 'no_shared_provider_configured', slug: requestedProvider };
|
||||
}
|
||||
const apiKey = await decryptedKeyFor(selected);
|
||||
return {
|
||||
provider: selected.providerType,
|
||||
apiKey,
|
||||
baseUrl: selected.baseUrl || undefined,
|
||||
fromAdmin: true,
|
||||
adminRecord: selected,
|
||||
};
|
||||
}
|
||||
|
||||
// Built-in provider ids are not admin slugs — short-circuit.
|
||||
if (isBuiltInProviderId(requestedProvider)) {
|
||||
return {
|
||||
provider: requestedProvider,
|
||||
apiKey: opts.apiKeyHeader || '',
|
||||
baseUrl: opts.baseUrlHeader || undefined,
|
||||
fromAdmin: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Not a built-in id → try to look it up as an admin slug.
|
||||
const admin = await getEnabledAdminProviderBySlug(requestedProvider);
|
||||
if (!admin) {
|
||||
return { error: 'no_shared_provider_configured', slug: requestedProvider };
|
||||
return { error: 'provider_unknown', slug: requestedProvider };
|
||||
}
|
||||
|
||||
const apiKey = await decryptedKeyFor(admin);
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import {
|
|||
ttsSegmentVariants,
|
||||
userPreferences,
|
||||
userDocumentProgress,
|
||||
userFolders,
|
||||
userOnboarding,
|
||||
} from '@openreader/database/schema';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy';
|
||||
|
|
@ -117,7 +115,7 @@ export async function claimAnonymousData(
|
|||
options?: { cleanupLegacySources?: boolean },
|
||||
) {
|
||||
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([
|
||||
|
|
@ -131,16 +129,13 @@ export async function claimAnonymousData(
|
|||
.where(eq(audiobooks.userId, unclaimedUserId)) as Promise<Array<{ id: string }>>,
|
||||
]);
|
||||
|
||||
const foldersClaimed = await transferUserFolders(unclaimedUserId, userId);
|
||||
const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed, onboardingClaimed] = await Promise.all([
|
||||
const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed] = await Promise.all([
|
||||
transferUserDocuments(unclaimedUserId, userId, { namespace, transferTts: true }),
|
||||
transferUserAudiobooks(unclaimedUserId, userId, namespace),
|
||||
transferUserPreferences(unclaimedUserId, userId),
|
||||
transferUserProgress(unclaimedUserId, userId),
|
||||
transferUserDocumentSettings(unclaimedUserId, userId),
|
||||
transferUserOnboarding(unclaimedUserId, userId),
|
||||
]);
|
||||
await db.delete(userFolders).where(eq(userFolders.userId, unclaimedUserId));
|
||||
|
||||
if (
|
||||
options?.cleanupLegacySources !== false
|
||||
|
|
@ -173,30 +168,9 @@ export async function claimAnonymousData(
|
|||
preferences: preferencesClaimed,
|
||||
progress: progressClaimed,
|
||||
documentSettings: documentSettingsClaimed,
|
||||
folders: foldersClaimed,
|
||||
onboarding: onboardingClaimed,
|
||||
};
|
||||
}
|
||||
|
||||
export async function transferUserFolders(fromUserId: string, toUserId: string): Promise<number> {
|
||||
if (!fromUserId || !toUserId || fromUserId === toUserId) return 0;
|
||||
const rows = await db.select().from(userFolders).where(eq(userFolders.userId, fromUserId));
|
||||
for (const row of rows) {
|
||||
await db.insert(userFolders).values({ ...row, userId: toUserId }).onConflictDoNothing();
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export async function transferUserOnboarding(fromUserId: string, toUserId: string): Promise<number> {
|
||||
if (!fromUserId || !toUserId || fromUserId === toUserId) return 0;
|
||||
const rows = await db.select().from(userOnboarding).where(eq(userOnboarding.userId, fromUserId));
|
||||
const row = rows[0];
|
||||
if (!row) return 0;
|
||||
await db.insert(userOnboarding).values({ ...row, userId: toUserId }).onConflictDoNothing();
|
||||
await db.delete(userOnboarding).where(eq(userOnboarding.userId, fromUserId));
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer documents from one userId to another.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -61,8 +61,6 @@ export type AppendUserExportArchiveInput = {
|
|||
exportedAtMs: number;
|
||||
profileData: unknown;
|
||||
preferences: unknown | null;
|
||||
folders?: unknown[];
|
||||
onboarding?: unknown | null;
|
||||
readingHistory: unknown[];
|
||||
ttsUsage: unknown[];
|
||||
jobEvents: unknown[];
|
||||
|
|
@ -146,8 +144,6 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
|
|||
exportedAtMs,
|
||||
profileData,
|
||||
preferences,
|
||||
folders = [],
|
||||
onboarding = null,
|
||||
readingHistory,
|
||||
ttsUsage,
|
||||
jobEvents,
|
||||
|
|
@ -175,8 +171,6 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
|
|||
if (preferences) {
|
||||
appendJson(archive, 'preferences.json', preferences);
|
||||
}
|
||||
appendJson(archive, 'folders.json', folders);
|
||||
if (onboarding) appendJson(archive, 'onboarding.json', onboarding);
|
||||
appendJson(archive, 'reading_history.json', readingHistory);
|
||||
appendJson(archive, 'tts_usage.json', ttsUsage);
|
||||
appendJson(archive, 'job_events.json', jobEvents);
|
||||
|
|
|
|||
|
|
@ -125,11 +125,6 @@ export function sanitizePreferencesPatch(
|
|||
case 'savedVoices':
|
||||
out[key] = sanitizeSavedVoices(value);
|
||||
break;
|
||||
case 'documentListState':
|
||||
if (isRecord(value)) {
|
||||
out[key] = { ...value, folders: [] } as unknown as SyncedPreferencesPatch[typeof key];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
52
src/lib/server/user/preferences-payload.ts
Normal file
52
src/lib/server/user/preferences-payload.ts
Normal 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;
|
||||
}
|
||||
45
src/lib/shared/onboarding-state.ts
Normal file
45
src/lib/shared/onboarding-state.ts
Normal 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;
|
||||
|
||||
|
|
@ -29,6 +29,8 @@ export function clampTtsSegmentMaxBlockLength(value: number | undefined | null):
|
|||
}
|
||||
|
||||
export interface AppConfigValues {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
viewType: ViewType;
|
||||
voiceSpeed: number;
|
||||
audioPlayerSpeed: number;
|
||||
|
|
@ -53,7 +55,10 @@ export interface AppConfigValues {
|
|||
epubWordHighlightEnabled: boolean;
|
||||
htmlHighlightEnabled: boolean;
|
||||
htmlWordHighlightEnabled: boolean;
|
||||
firstVisit: boolean;
|
||||
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
|
||||
// value isn't actually selectable in shared-provider mode.
|
||||
return {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
viewType: 'single',
|
||||
voiceSpeed: 1,
|
||||
audioPlayerSpeed: 1,
|
||||
|
|
@ -94,6 +101,7 @@ export function getAppConfigDefaults(): AppConfigValues {
|
|||
epubWordHighlightEnabled: true,
|
||||
htmlHighlightEnabled: true,
|
||||
htmlWordHighlightEnabled: true,
|
||||
firstVisit: false,
|
||||
documentListState: {
|
||||
sortBy: 'name',
|
||||
sortDirection: 'asc',
|
||||
|
|
@ -102,6 +110,8 @@ export function getAppConfigDefaults(): AppConfigValues {
|
|||
showHint: true,
|
||||
viewMode: 'grid',
|
||||
},
|
||||
privacyAccepted: false,
|
||||
documentsMigrationPrompted: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ export interface BaseDocument {
|
|||
size: number;
|
||||
lastModified: number;
|
||||
recentlyOpenedAt?: number;
|
||||
contentVersion?: string;
|
||||
type: DocumentType;
|
||||
scope?: 'user';
|
||||
folderId?: string;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ export const SYNCED_PREFERENCE_KEYS = [
|
|||
'epubWordHighlightEnabled',
|
||||
'htmlHighlightEnabled',
|
||||
'htmlWordHighlightEnabled',
|
||||
'documentListState',
|
||||
] as const;
|
||||
|
||||
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ test.describe('Document folders and hint persistence', () => {
|
|||
// Hint should disappear
|
||||
await expect(hint).toHaveCount(0);
|
||||
|
||||
// Ensure the dismissal has been persisted to server preferences before reloading
|
||||
// Ensure the dismissal has been persisted to IndexedDB before reloading
|
||||
await waitForDocumentListHintPersist(page, false);
|
||||
|
||||
// Reload and ensure it remains dismissed
|
||||
|
|
|
|||
|
|
@ -533,14 +533,27 @@ export async function triggerViewportResize(page: Page, width: number, height: n
|
|||
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) {
|
||||
await page.waitForFunction(async (exp) => {
|
||||
try {
|
||||
const response = await fetch('/api/user/state/preferences', { cache: 'no-store' });
|
||||
if (!response.ok) return false;
|
||||
const item = await response.json() as { preferences?: { documentListState?: unknown } };
|
||||
const state = item.preferences?.documentListState;
|
||||
const openDb = () => new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open('openreader-db');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
const db = await openDb();
|
||||
const readConfig = () => new Promise<unknown>((resolve, reject) => {
|
||||
const tx = db.transaction(['app-config'], 'readonly');
|
||||
const store = tx.objectStore('app-config');
|
||||
const getReq = store.get('singleton');
|
||||
getReq.onsuccess = () => resolve(getReq.result);
|
||||
getReq.onerror = () => reject(getReq.error);
|
||||
});
|
||||
const item = await readConfig();
|
||||
db.close();
|
||||
if (!item || typeof item !== 'object') return false;
|
||||
const state = (item as { documentListState?: unknown }).documentListState;
|
||||
if (!state || typeof state !== 'object') return false;
|
||||
const showHint = (state as { showHint?: unknown }).showHint;
|
||||
return typeof showHint === 'boolean' && showHint === exp;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
114
tests/unit/document-cache.vitest.spec.ts
Normal file
114
tests/unit/document-cache.vitest.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,7 @@ import {
|
|||
isDirectionalEpubLocation,
|
||||
shouldNavigateToDifferentCfi,
|
||||
shouldPersistEpubLocation,
|
||||
} from '../../src/lib/client/epub/location-controller';
|
||||
} from '../../src/hooks/epub/useEPUBLocationController';
|
||||
|
||||
describe('EPUB location controller helpers', () => {
|
||||
test('detects directional locations', () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ describe('onboarding flow resolver', () => {
|
|||
privacyAccepted: false,
|
||||
claimEligible: true,
|
||||
claimHasData: true,
|
||||
migrationRequired: true,
|
||||
changelogPending: true,
|
||||
});
|
||||
|
||||
|
|
@ -21,18 +22,33 @@ describe('onboarding flow resolver', () => {
|
|||
privacyAccepted: true,
|
||||
claimEligible: true,
|
||||
claimHasData: true,
|
||||
migrationRequired: true,
|
||||
changelogPending: true,
|
||||
});
|
||||
|
||||
expect(step).toBe('claim');
|
||||
});
|
||||
|
||||
test('resolves migration when no claim is needed', () => {
|
||||
const step = resolveNextOnboardingStep({
|
||||
privacyRequired: true,
|
||||
privacyAccepted: true,
|
||||
claimEligible: true,
|
||||
claimHasData: false,
|
||||
migrationRequired: true,
|
||||
changelogPending: true,
|
||||
});
|
||||
|
||||
expect(step).toBe('migration');
|
||||
});
|
||||
|
||||
test('resolves changelog when prior steps are clear', () => {
|
||||
const step = resolveNextOnboardingStep({
|
||||
privacyRequired: true,
|
||||
privacyAccepted: true,
|
||||
claimEligible: true,
|
||||
claimHasData: false,
|
||||
migrationRequired: false,
|
||||
changelogPending: true,
|
||||
});
|
||||
|
||||
|
|
@ -45,6 +61,7 @@ describe('onboarding flow resolver', () => {
|
|||
privacyAccepted: true,
|
||||
claimEligible: true,
|
||||
claimHasData: false,
|
||||
migrationRequired: false,
|
||||
changelogPending: false,
|
||||
});
|
||||
const minimalStep = resolveNextOnboardingStep({
|
||||
|
|
@ -52,6 +69,7 @@ describe('onboarding flow resolver', () => {
|
|||
privacyAccepted: false,
|
||||
claimEligible: false,
|
||||
claimHasData: false,
|
||||
migrationRequired: false,
|
||||
changelogPending: false,
|
||||
});
|
||||
|
||||
|
|
|
|||
19
tests/unit/onboarding-state.vitest.spec.ts
Normal file
19
tests/unit/onboarding-state.vitest.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -25,8 +25,6 @@ describe('transferUserDocuments', () => {
|
|||
size INTEGER NOT NULL,
|
||||
last_modified INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
folder_id TEXT,
|
||||
recently_opened_at INTEGER,
|
||||
parse_state TEXT,
|
||||
parsed_json_key TEXT,
|
||||
created_at INTEGER,
|
||||
|
|
@ -151,8 +149,6 @@ describe('transferUserDocuments', () => {
|
|||
size INTEGER NOT NULL,
|
||||
last_modified INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
folder_id TEXT,
|
||||
recently_opened_at INTEGER,
|
||||
parse_state TEXT,
|
||||
parsed_json_key TEXT,
|
||||
created_at INTEGER,
|
||||
|
|
|
|||
|
|
@ -533,6 +533,7 @@ describe('config helpers', () => {
|
|||
test('builds synced preference patches and honors non-default filtering', () => {
|
||||
expect(buildSyncedPreferencePatch({
|
||||
voiceSpeed: 1.2,
|
||||
baseUrl: 'http://localhost',
|
||||
ttsModel: 'kokoro',
|
||||
})).toEqual({
|
||||
voiceSpeed: 1.2,
|
||||
|
|
|
|||
81
tests/unit/user-preferences-sync.vitest.spec.ts
Normal file
81
tests/unit/user-preferences-sync.vitest.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers';
|
||||
|
||||
interface HtmlDocumentRow {
|
||||
id?: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
type HashCheckResult =
|
||||
| { ok: true; storedId: string; computedId: string }
|
||||
| { ok: false; reason: 'Missing stored html document' | 'Hash mismatch' | '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.beforeEach(async ({ page }, testInfo) => {
|
||||
|
|
@ -53,24 +58,38 @@ test.describe('Document Upload Tests', () => {
|
|||
await expectDocumentListed(page, 'sample.txt');
|
||||
|
||||
const result = await page.evaluate<HashCheckResult>(async () => {
|
||||
const listRes = await fetch('/api/documents', { cache: 'no-store' });
|
||||
const docs = listRes.ok
|
||||
? ((await listRes.json()) as { documents?: Array<{ id: string; name: string }> }).documents ?? []
|
||||
: [];
|
||||
const doc = docs.find((item) => item.name === 'sample.txt');
|
||||
if (!doc?.id) return { ok: false, reason: 'Missing stored html document' as const };
|
||||
const idb = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open('openreader-db');
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
|
||||
const contentRes = await fetch(`/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`, { cache: 'no-store' });
|
||||
if (!contentRes.ok) return { ok: false, reason: 'Content fetch failed' as const, storedId: doc.id };
|
||||
const digest = await crypto.subtle.digest('SHA-256', await contentRes.arrayBuffer());
|
||||
const computedId = Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
try {
|
||||
const docs = await new Promise<HtmlDocumentRow[]>((resolve, reject) => {
|
||||
const tx = idb.transaction('html-documents', 'readonly');
|
||||
const store = tx.objectStore('html-documents');
|
||||
const request = store.getAll();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result as HtmlDocumentRow[]);
|
||||
});
|
||||
|
||||
if (computedId === doc.id) {
|
||||
return { ok: true as const, storedId: doc.id, computedId };
|
||||
if (!docs[0]?.data || !docs[0]?.id) {
|
||||
return { ok: false, reason: 'Missing stored html document' as const };
|
||||
}
|
||||
|
||||
const bytes = new TextEncoder().encode(String(docs[0].data));
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue