Compare commits
1 commit
main
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2bbd8fbef |
93 changed files with 5820 additions and 3539 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 API key / base URL fields are hidden — those credentials never leave the server.
|
||||||
- The TTS request still goes through the user's browser, but the server replaces the slug with the matching admin row's decrypted key and base URL before calling the upstream provider.
|
- The TTS request still goes through the user's browser, but the server replaces the slug with the matching admin row's decrypted key and base URL before calling the upstream provider.
|
||||||
- The user's per-request `x-openai-key` / `x-openai-base-url` headers are ignored for shared slugs.
|
- TTS credentials are resolved only from admin-managed shared providers and are never accepted from client request headers.
|
||||||
|
|
||||||
Whether users can supply their own personal built-in provider keys is controlled by the site feature `restrictUserApiKeys`:
|
Whether users can supply their own personal built-in provider keys is controlled by the site feature `restrictUserApiKeys`:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ title: Stack
|
||||||
- Interactions: `react-dnd`, `react-dropzone`
|
- Interactions: `react-dnd`, `react-dropzone`
|
||||||
- Server state: [TanStack Query](https://tanstack.com/query) (React Query v5)
|
- Server state: [TanStack Query](https://tanstack.com/query) (React Query v5)
|
||||||
- Authentication: [Better Auth](https://www.better-auth.com/) client SDK
|
- Authentication: [Better Auth](https://www.better-auth.com/) client SDK
|
||||||
- Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB)
|
- Browser blob performance cache: Cache Storage (evictable; never authoritative)
|
||||||
- Audio playback: [Howler.js](https://howlerjs.com/)
|
- Audio playback: [Howler.js](https://howlerjs.com/)
|
||||||
- Notifications: `react-hot-toast`
|
- Notifications: `react-hot-toast`
|
||||||
- Document rendering:
|
- Document rendering:
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ const UI_ARCHITECTURE_FILES = [
|
||||||
"src/components/doclist/**/*.{ts,tsx}",
|
"src/components/doclist/**/*.{ts,tsx}",
|
||||||
"src/components/documents/DocumentUploader.tsx",
|
"src/components/documents/DocumentUploader.tsx",
|
||||||
"src/components/documents/DocumentSelectionModal.tsx",
|
"src/components/documents/DocumentSelectionModal.tsx",
|
||||||
"src/components/documents/DexieMigrationModal.tsx",
|
|
||||||
"src/components/documents/ZoomControl.tsx",
|
"src/components/documents/ZoomControl.tsx",
|
||||||
"src/components/player/**/*.{ts,tsx}",
|
"src/components/player/**/*.{ts,tsx}",
|
||||||
"src/components/reader/**/*.{ts,tsx}",
|
"src/components/reader/**/*.{ts,tsx}",
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,6 @@
|
||||||
"cmpstr": "^3.3.0",
|
"cmpstr": "^3.3.0",
|
||||||
"compromise": "^14.15.1",
|
"compromise": "^14.15.1",
|
||||||
"core-js": "^3.49.0",
|
"core-js": "^3.49.0",
|
||||||
"dexie": "^4.4.3",
|
|
||||||
"dexie-react-hooks": "^4.4.0",
|
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"epubjs": "^0.3.93",
|
"epubjs": "^0.3.93",
|
||||||
|
|
@ -102,8 +100,8 @@
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^15.5.19",
|
"eslint-config-next": "^15.5.19",
|
||||||
"postcss": "^8.5.15",
|
|
||||||
"openapi-typescript": "^7.10.1",
|
"openapi-typescript": "^7.10.1",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export class FakeControlPlane {
|
||||||
private readonly artifactKeys = new Set<string>();
|
private readonly artifactKeys = new Set<string>();
|
||||||
private nextOpId = 1;
|
private nextOpId = 1;
|
||||||
|
|
||||||
readonly deps: ComputeWorkerRouteDeps = {
|
readonly deps = {
|
||||||
orchestrator: {
|
orchestrator: {
|
||||||
enqueueOrReuse: async (request) => this.enqueueOrReuse(request),
|
enqueueOrReuse: async (request) => this.enqueueOrReuse(request),
|
||||||
markRunning: async (input) => this.updateState(input.opId, {
|
markRunning: async (input) => this.updateState(input.opId, {
|
||||||
|
|
@ -75,7 +75,7 @@ export class FakeControlPlane {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
artifactExists: async (key) => this.artifactKeys.has(key),
|
artifactExists: async (key) => this.artifactKeys.has(key),
|
||||||
};
|
} satisfies ComputeWorkerRouteDeps;
|
||||||
|
|
||||||
seedState(state: ComputeState): void {
|
seedState(state: ComputeState): void {
|
||||||
this.stateByOpId.set(state.opId, state);
|
this.stateByOpId.set(state.opId, state);
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ describe('orphan recovery', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstSweep = await recoverOrphanedOperations({
|
const firstSweep = await recoverOrphanedOperations({
|
||||||
operationStateStore: fake.deps.operationStateStore!,
|
operationStateStore: fake.deps.operationStateStore,
|
||||||
orchestrator: fake.deps.orchestrator!,
|
orchestrator: fake.deps.orchestrator,
|
||||||
whisperTimeoutMs: 30_000,
|
whisperTimeoutMs: 30_000,
|
||||||
pdfTimeoutMs: 300_000,
|
pdfTimeoutMs: 300_000,
|
||||||
opStaleMs: 1_800_000,
|
opStaleMs: 1_800_000,
|
||||||
|
|
@ -38,8 +38,8 @@ describe('orphan recovery', () => {
|
||||||
vi.advanceTimersByTime(31_000);
|
vi.advanceTimersByTime(31_000);
|
||||||
|
|
||||||
const secondSweep = await recoverOrphanedOperations({
|
const secondSweep = await recoverOrphanedOperations({
|
||||||
operationStateStore: fake.deps.operationStateStore!,
|
operationStateStore: fake.deps.operationStateStore,
|
||||||
orchestrator: fake.deps.orchestrator!,
|
orchestrator: fake.deps.orchestrator,
|
||||||
whisperTimeoutMs: 30_000,
|
whisperTimeoutMs: 30_000,
|
||||||
pdfTimeoutMs: 300_000,
|
pdfTimeoutMs: 300_000,
|
||||||
opStaleMs: 1_800_000,
|
opStaleMs: 1_800_000,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
CREATE TABLE "user_folders" (
|
||||||
|
"id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"position" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
|
||||||
|
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
|
||||||
|
CONSTRAINT "user_folders_id_user_id_pk" PRIMARY KEY("id","user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_onboarding" (
|
||||||
|
"user_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"privacy_accepted_at_ms" bigint,
|
||||||
|
"last_seen_app_version" text,
|
||||||
|
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
|
||||||
|
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "documents" ADD COLUMN "folder_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "documents" ADD COLUMN "recently_opened_at" bigint;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_folders" ADD CONSTRAINT "user_folders_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_user_folders_user_position" ON "user_folders" USING btree ("user_id","position");--> statement-breakpoint
|
||||||
|
ALTER TABLE "documents" ADD CONSTRAINT "documents_folder_id_user_id_user_folders_id_user_id_fk" FOREIGN KEY ("folder_id","user_id") REFERENCES "public"."user_folders"("id","user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_documents_user_id_folder" ON "documents" USING btree ("user_id","folder_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_documents_user_id_recently_opened" ON "documents" USING btree ("user_id","recently_opened_at");
|
||||||
2263
packages/database/migrations/postgres/meta/0012_snapshot.json
Normal file
2263
packages/database/migrations/postgres/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -85,6 +85,13 @@
|
||||||
"when": 1780851107819,
|
"when": 1780851107819,
|
||||||
"tag": "0011_scheduled-tasks",
|
"tag": "0011_scheduled-tasks",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781466220153,
|
||||||
|
"tag": "0012_user_additions",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
46
packages/database/migrations/sqlite/0012_user_additions.sql
Normal file
46
packages/database/migrations/sqlite/0012_user_additions.sql
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
CREATE TABLE `user_folders` (
|
||||||
|
`id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`position` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||||
|
PRIMARY KEY(`id`, `user_id`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_user_folders_user_position` ON `user_folders` (`user_id`,`position`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_onboarding` (
|
||||||
|
`user_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`privacy_accepted_at_ms` integer,
|
||||||
|
`last_seen_app_version` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_documents` (
|
||||||
|
`id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`size` integer NOT NULL,
|
||||||
|
`last_modified` integer NOT NULL,
|
||||||
|
`file_path` text NOT NULL,
|
||||||
|
`folder_id` text,
|
||||||
|
`recently_opened_at` integer,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||||
|
PRIMARY KEY(`id`, `user_id`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`folder_id`,`user_id`) REFERENCES `user_folders`(`id`,`user_id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_documents`("id", "user_id", "name", "type", "size", "last_modified", "file_path", "folder_id", "recently_opened_at", "created_at") SELECT "id", "user_id", "name", "type", "size", "last_modified", "file_path", NULL, NULL, "created_at" FROM `documents`;--> statement-breakpoint
|
||||||
|
DROP TABLE `documents`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_documents` RENAME TO `documents`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_documents_user_id` ON `documents` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_documents_user_id_last_modified` ON `documents` (`user_id`,`last_modified`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_documents_user_id_folder` ON `documents` (`user_id`,`folder_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_documents_user_id_recently_opened` ON `documents` (`user_id`,`recently_opened_at`);
|
||||||
2084
packages/database/migrations/sqlite/meta/0012_snapshot.json
Normal file
2084
packages/database/migrations/sqlite/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -85,6 +85,13 @@
|
||||||
"when": 1780851107505,
|
"when": 1780851107505,
|
||||||
"tag": "0011_scheduled-tasks",
|
"tag": "0011_scheduled-tasks",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781466219864,
|
||||||
|
"tag": "0012_user_additions",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -7,11 +7,13 @@ const usePostgres = !!process.env.POSTGRES_URL;
|
||||||
// and are NOT part of the Drizzle schema. Only app-specific tables are exported here.
|
// and are NOT part of the Drizzle schema. Only app-specific tables are exported here.
|
||||||
|
|
||||||
export const documents = usePostgres ? postgresSchema.documents : sqliteSchema.documents;
|
export const documents = usePostgres ? postgresSchema.documents : sqliteSchema.documents;
|
||||||
|
export const userFolders = usePostgres ? postgresSchema.userFolders : sqliteSchema.userFolders;
|
||||||
export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema.audiobooks;
|
export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema.audiobooks;
|
||||||
export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters;
|
export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters;
|
||||||
export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars;
|
export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars;
|
||||||
export const userJobEvents = usePostgres ? postgresSchema.userJobEvents : sqliteSchema.userJobEvents;
|
export const userJobEvents = usePostgres ? postgresSchema.userJobEvents : sqliteSchema.userJobEvents;
|
||||||
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
|
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
|
||||||
|
export const userOnboarding = usePostgres ? postgresSchema.userOnboarding : sqliteSchema.userOnboarding;
|
||||||
export const documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings;
|
export const documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings;
|
||||||
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;
|
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;
|
||||||
export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews;
|
export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,18 @@ import { user } from './schema_auth_postgres';
|
||||||
|
|
||||||
const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`;
|
const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`;
|
||||||
|
|
||||||
|
export const userFolders = pgTable('user_folders', {
|
||||||
|
id: text('id').notNull(),
|
||||||
|
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
position: integer('position').notNull().default(0),
|
||||||
|
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
|
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
|
}, (table) => [
|
||||||
|
primaryKey({ columns: [table.id, table.userId] }),
|
||||||
|
index('idx_user_folders_user_position').on(table.userId, table.position),
|
||||||
|
]);
|
||||||
|
|
||||||
export const documents = pgTable('documents', {
|
export const documents = pgTable('documents', {
|
||||||
id: text('id').notNull(),
|
id: text('id').notNull(),
|
||||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
|
@ -12,11 +24,19 @@ export const documents = pgTable('documents', {
|
||||||
size: bigint('size', { mode: 'number' }).notNull(),
|
size: bigint('size', { mode: 'number' }).notNull(),
|
||||||
lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
|
lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
|
||||||
filePath: text('file_path').notNull(),
|
filePath: text('file_path').notNull(),
|
||||||
|
folderId: text('folder_id'),
|
||||||
|
recentlyOpenedAt: bigint('recently_opened_at', { mode: 'number' }),
|
||||||
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.id, table.userId] }),
|
primaryKey({ columns: [table.id, table.userId] }),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.folderId, table.userId],
|
||||||
|
foreignColumns: [userFolders.id, userFolders.userId],
|
||||||
|
}),
|
||||||
index('idx_documents_user_id').on(table.userId),
|
index('idx_documents_user_id').on(table.userId),
|
||||||
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
|
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
|
||||||
|
index('idx_documents_user_id_folder').on(table.userId, table.folderId),
|
||||||
|
index('idx_documents_user_id_recently_opened').on(table.userId, table.recentlyOpenedAt),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const audiobooks = pgTable('audiobooks', {
|
export const audiobooks = pgTable('audiobooks', {
|
||||||
|
|
@ -89,6 +109,14 @@ export const userPreferences = pgTable('user_preferences', {
|
||||||
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const userOnboarding = pgTable('user_onboarding', {
|
||||||
|
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
privacyAcceptedAtMs: bigint('privacy_accepted_at_ms', { mode: 'number' }),
|
||||||
|
lastSeenAppVersion: text('last_seen_app_version'),
|
||||||
|
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
|
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
|
});
|
||||||
|
|
||||||
export const documentSettings = pgTable('document_settings', {
|
export const documentSettings = pgTable('document_settings', {
|
||||||
documentId: text('document_id').notNull(),
|
documentId: text('document_id').notNull(),
|
||||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,18 @@ import { user } from './schema_auth_sqlite';
|
||||||
|
|
||||||
const SQLITE_NOW_MS = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
const SQLITE_NOW_MS = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||||
|
|
||||||
|
export const userFolders = sqliteTable('user_folders', {
|
||||||
|
id: text('id').notNull(),
|
||||||
|
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
position: integer('position').notNull().default(0),
|
||||||
|
createdAt: integer('created_at').default(SQLITE_NOW_MS),
|
||||||
|
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||||
|
}, (table) => [
|
||||||
|
primaryKey({ columns: [table.id, table.userId] }),
|
||||||
|
index('idx_user_folders_user_position').on(table.userId, table.position),
|
||||||
|
]);
|
||||||
|
|
||||||
export const documents = sqliteTable('documents', {
|
export const documents = sqliteTable('documents', {
|
||||||
id: text('id').notNull(),
|
id: text('id').notNull(),
|
||||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
|
@ -12,11 +24,19 @@ export const documents = sqliteTable('documents', {
|
||||||
size: integer('size').notNull(),
|
size: integer('size').notNull(),
|
||||||
lastModified: integer('last_modified').notNull(),
|
lastModified: integer('last_modified').notNull(),
|
||||||
filePath: text('file_path').notNull(),
|
filePath: text('file_path').notNull(),
|
||||||
|
folderId: text('folder_id'),
|
||||||
|
recentlyOpenedAt: integer('recently_opened_at'),
|
||||||
createdAt: integer('created_at').default(SQLITE_NOW_MS),
|
createdAt: integer('created_at').default(SQLITE_NOW_MS),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.id, table.userId] }),
|
primaryKey({ columns: [table.id, table.userId] }),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.folderId, table.userId],
|
||||||
|
foreignColumns: [userFolders.id, userFolders.userId],
|
||||||
|
}),
|
||||||
index('idx_documents_user_id').on(table.userId),
|
index('idx_documents_user_id').on(table.userId),
|
||||||
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
|
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
|
||||||
|
index('idx_documents_user_id_folder').on(table.userId, table.folderId),
|
||||||
|
index('idx_documents_user_id_recently_opened').on(table.userId, table.recentlyOpenedAt),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const audiobooks = sqliteTable('audiobooks', {
|
export const audiobooks = sqliteTable('audiobooks', {
|
||||||
|
|
@ -89,6 +109,14 @@ export const userPreferences = sqliteTable('user_preferences', {
|
||||||
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const userOnboarding = sqliteTable('user_onboarding', {
|
||||||
|
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
privacyAcceptedAtMs: integer('privacy_accepted_at_ms'),
|
||||||
|
lastSeenAppVersion: text('last_seen_app_version'),
|
||||||
|
createdAt: integer('created_at').default(SQLITE_NOW_MS),
|
||||||
|
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||||
|
});
|
||||||
|
|
||||||
export const documentSettings = sqliteTable('document_settings', {
|
export const documentSettings = sqliteTable('document_settings', {
|
||||||
documentId: text('document_id').notNull(),
|
documentId: text('document_id').notNull(),
|
||||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,6 @@ importers:
|
||||||
core-js:
|
core-js:
|
||||||
specifier: ^3.49.0
|
specifier: ^3.49.0
|
||||||
version: 3.49.0
|
version: 3.49.0
|
||||||
dexie:
|
|
||||||
specifier: ^4.4.3
|
|
||||||
version: 4.4.3
|
|
||||||
dexie-react-hooks:
|
|
||||||
specifier: ^4.4.0
|
|
||||||
version: 4.4.0(dexie@4.4.3)(react@19.2.7)
|
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.4.2
|
specifier: ^17.4.2
|
||||||
version: 17.4.2
|
version: 17.4.2
|
||||||
|
|
@ -2606,15 +2600,6 @@ packages:
|
||||||
devlop@1.1.0:
|
devlop@1.1.0:
|
||||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||||
|
|
||||||
dexie-react-hooks@4.4.0:
|
|
||||||
resolution: {integrity: sha512-ObLXBS5+4BJU8vtSvBx6b9fY6zZYgniAtwxzjCHsUQadgbqYN6935X2/1TWw4Rf2N1aZV1io5/ziox4vKuxABA==}
|
|
||||||
peerDependencies:
|
|
||||||
dexie: '>=4.2.0-alpha.1 <5.0.0'
|
|
||||||
react: '>=16'
|
|
||||||
|
|
||||||
dexie@4.4.3:
|
|
||||||
resolution: {integrity: sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==}
|
|
||||||
|
|
||||||
didyoumean@1.2.2:
|
didyoumean@1.2.2:
|
||||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||||
|
|
||||||
|
|
@ -7089,13 +7074,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
|
|
||||||
dexie-react-hooks@4.4.0(dexie@4.4.3)(react@19.2.7):
|
|
||||||
dependencies:
|
|
||||||
dexie: 4.4.3
|
|
||||||
react: 19.2.7
|
|
||||||
|
|
||||||
dexie@4.4.3: {}
|
|
||||||
|
|
||||||
didyoumean@1.2.2: {}
|
didyoumean@1.2.2: {}
|
||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
||||||
import { EPUBViewer } from '@/components/views/EPUBViewer';
|
import { EPUBViewer } from '@/components/views/EPUBViewer';
|
||||||
|
|
@ -14,7 +14,6 @@ import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import { resolveDocumentId } from '@/lib/client/dexie';
|
|
||||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
|
|
@ -26,7 +25,6 @@ import { useEpubDocument } from './useEpubDocument';
|
||||||
export default function EPUBPage() {
|
export default function EPUBPage() {
|
||||||
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const router = useRouter();
|
|
||||||
const routeDocumentId = typeof id === 'string' ? id : undefined;
|
const routeDocumentId = typeof id === 'string' ? id : undefined;
|
||||||
const epubState = useEpubDocument(routeDocumentId);
|
const epubState = useEpubDocument(routeDocumentId);
|
||||||
const {
|
const {
|
||||||
|
|
@ -62,19 +60,13 @@ export default function EPUBPage() {
|
||||||
|
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
console.log('Loading new epub (from page.tsx)');
|
console.log('Loading new epub (from page.tsx)');
|
||||||
let didRedirect = false;
|
|
||||||
let startedLoad = false;
|
let startedLoad = false;
|
||||||
try {
|
try {
|
||||||
if (!routeDocumentId) {
|
if (!routeDocumentId) {
|
||||||
setError('Document not found');
|
setError('Document not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolved = await resolveDocumentId(routeDocumentId);
|
const resolved = routeDocumentId;
|
||||||
if (resolved !== routeDocumentId) {
|
|
||||||
didRedirect = true;
|
|
||||||
router.replace(`/epub/${resolved}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadedDocIdRef.current === resolved) {
|
if (loadedDocIdRef.current === resolved) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -95,11 +87,11 @@ export default function EPUBPage() {
|
||||||
if (startedLoad) {
|
if (startedLoad) {
|
||||||
inFlightDocIdRef.current = null;
|
inFlightDocIdRef.current = null;
|
||||||
}
|
}
|
||||||
if (!didRedirect && startedLoad) {
|
if (startedLoad) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [routeDocumentId, router, setCurrentDocument, stop]);
|
}, [routeDocumentId, setCurrentDocument, stop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading) return;
|
if (!isLoading) return;
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
// Configuration context to get TTS settings
|
// Configuration context to get TTS settings
|
||||||
const {
|
const {
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
providerRef,
|
providerRef,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
epubTheme,
|
epubTheme,
|
||||||
|
|
@ -195,7 +193,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
}, [resetHighlightState, setCurrDocPages, stop]);
|
}, [resetHighlightState, setCurrDocPages, stop]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the current document based on its ID by fetching from IndexedDB
|
* Sets the current document based on its ID using server metadata and the browser blob cache.
|
||||||
* @param {string} id - The unique identifier of the document
|
* @param {string} id - The unique identifier of the document
|
||||||
* @throws {Error} When document data is empty or retrieval fails
|
* @throws {Error} When document data is empty or retrieval fails
|
||||||
*/
|
*/
|
||||||
|
|
@ -478,8 +476,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({
|
const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({
|
||||||
bookRef,
|
bookRef,
|
||||||
tocRef,
|
tocRef,
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
providerRef,
|
providerRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
|
||||||
import { HTMLViewer } from '@/components/views/HTMLViewer';
|
import { HTMLViewer } from '@/components/views/HTMLViewer';
|
||||||
|
|
@ -9,7 +9,6 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { useTTS } from "@/contexts/TTSContext";
|
import { useTTS } from "@/contexts/TTSContext";
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
import { resolveDocumentId } from '@/lib/client/dexie';
|
|
||||||
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
|
||||||
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
|
||||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||||
|
|
@ -26,7 +25,6 @@ import { useHtmlDocument } from './useHtmlDocument';
|
||||||
export default function HTMLPage() {
|
export default function HTMLPage() {
|
||||||
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const router = useRouter();
|
|
||||||
const routeDocumentId = typeof id === 'string' ? id : undefined;
|
const routeDocumentId = typeof id === 'string' ? id : undefined;
|
||||||
const htmlState = useHtmlDocument();
|
const htmlState = useHtmlDocument();
|
||||||
const {
|
const {
|
||||||
|
|
@ -63,19 +61,13 @@ export default function HTMLPage() {
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
if (!isLoading) return;
|
if (!isLoading) return;
|
||||||
console.log('Loading new HTML document (from page.tsx)');
|
console.log('Loading new HTML document (from page.tsx)');
|
||||||
let didRedirect = false;
|
|
||||||
let startedLoad = false;
|
let startedLoad = false;
|
||||||
try {
|
try {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setError('Document not found');
|
setError('Document not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolved = await resolveDocumentId(id as string);
|
const resolved = id as string;
|
||||||
if (resolved !== (id as string)) {
|
|
||||||
didRedirect = true;
|
|
||||||
router.replace(`/html/${resolved}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadedDocIdRef.current === resolved) {
|
if (loadedDocIdRef.current === resolved) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -96,11 +88,11 @@ export default function HTMLPage() {
|
||||||
if (startedLoad) {
|
if (startedLoad) {
|
||||||
inFlightDocIdRef.current = null;
|
inFlightDocIdRef.current = null;
|
||||||
}
|
}
|
||||||
if (!didRedirect && startedLoad) {
|
if (startedLoad) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isLoading, id, router, setCurrentDocument, stop]);
|
}, [isLoading, id, setCurrentDocument, stop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading) return;
|
if (!isLoading) return;
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,6 @@ function buildFullDocumentText(blocks: HtmlBlock[]): string {
|
||||||
export function useHtmlDocument(): HtmlDocumentState {
|
export function useHtmlDocument(): HtmlDocumentState {
|
||||||
const { setText: setTTSText, stop, setIsEPUB } = useTTS();
|
const { setText: setTTSText, stop, setIsEPUB } = useTTS();
|
||||||
const {
|
const {
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
providerRef,
|
providerRef,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
@ -173,8 +171,6 @@ export function useHtmlDocument(): HtmlDocumentState {
|
||||||
try {
|
try {
|
||||||
return await runAudiobookGeneration({
|
return await runAudiobookGeneration({
|
||||||
adapter: audiobookAdapter,
|
adapter: audiobookAdapter,
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
defaultProvider: providerRef,
|
defaultProvider: providerRef,
|
||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
|
|
@ -189,7 +185,7 @@ export function useHtmlDocument(): HtmlDocumentState {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[audiobookAdapter, apiKey, baseUrl, providerRef],
|
[audiobookAdapter, providerRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const regenerateChapter = useCallback(
|
const regenerateChapter = useCallback(
|
||||||
|
|
@ -208,8 +204,6 @@ export function useHtmlDocument(): HtmlDocumentState {
|
||||||
bookId,
|
bookId,
|
||||||
format,
|
format,
|
||||||
signal,
|
signal,
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
defaultProvider: providerRef,
|
defaultProvider: providerRef,
|
||||||
settings,
|
settings,
|
||||||
retryOptions,
|
retryOptions,
|
||||||
|
|
@ -222,7 +216,7 @@ export function useHtmlDocument(): HtmlDocumentState {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[audiobookAdapter, apiKey, baseUrl, providerRef],
|
[audiobookAdapter, providerRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||||
githubAuthEnabled={githubAuthEnabled}
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
>
|
>
|
||||||
{/* ConfigProvider lives here, in the shared (app) layout, so it stays
|
{/* Keep the preferences query/context mounted across library and reader navigation. */}
|
||||||
mounted across library <-> reader navigation. Mounting it per-route
|
|
||||||
re-ran the Dexie/server hydration race on every navigation, causing
|
|
||||||
the reader to briefly use the admin-default provider until a full
|
|
||||||
page refresh. A single shared instance keeps the user's saved
|
|
||||||
provider hydrated the whole time. */}
|
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<AppMain>{children}</AppMain>
|
<AppMain>{children}</AppMain>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
||||||
import { resolveDocumentId } from '@/lib/client/dexie';
|
|
||||||
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
|
|
@ -99,7 +98,6 @@ export default function PDFViewerPage() {
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
if (!isLoading) return; // Prevent calls when not loading new doc
|
if (!isLoading) return; // Prevent calls when not loading new doc
|
||||||
console.log('Loading new document (from page.tsx)');
|
console.log('Loading new document (from page.tsx)');
|
||||||
let didRedirect = false;
|
|
||||||
let startedLoad = false;
|
let startedLoad = false;
|
||||||
let loadSucceeded = false;
|
let loadSucceeded = false;
|
||||||
try {
|
try {
|
||||||
|
|
@ -107,12 +105,7 @@ export default function PDFViewerPage() {
|
||||||
setError('Document not found');
|
setError('Document not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolved = await resolveDocumentId(id as string);
|
const resolved = id as string;
|
||||||
if (resolved !== (id as string)) {
|
|
||||||
didRedirect = true;
|
|
||||||
router.replace(`/pdf/${resolved}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadedDocIdRef.current === resolved) {
|
if (loadedDocIdRef.current === resolved) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -125,12 +118,18 @@ export default function PDFViewerPage() {
|
||||||
inFlightDocIdRef.current = resolved;
|
inFlightDocIdRef.current = resolved;
|
||||||
stop(); // Reset TTS when loading new document
|
stop(); // Reset TTS when loading new document
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
const loaded = await setCurrentDocument(resolved);
|
const result = await setCurrentDocument(resolved);
|
||||||
if (loaded) {
|
if (result === 'loaded') {
|
||||||
loadSucceeded = true;
|
loadSucceeded = true;
|
||||||
loadedDocIdRef.current = resolved;
|
loadedDocIdRef.current = resolved;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (result === 'superseded') {
|
||||||
|
// A newer load (or unmount) is now authoritative; it owns the loading
|
||||||
|
// lifecycle. Bail without surfacing an error to avoid the spurious
|
||||||
|
// "Failed to load" screen on first launch.
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (attempt === 0) {
|
if (attempt === 0) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
}
|
}
|
||||||
|
|
@ -145,11 +144,11 @@ export default function PDFViewerPage() {
|
||||||
if (startedLoad) {
|
if (startedLoad) {
|
||||||
inFlightDocIdRef.current = null;
|
inFlightDocIdRef.current = null;
|
||||||
}
|
}
|
||||||
if (!didRedirect && startedLoad && loadSucceeded) {
|
if (startedLoad && loadSucceeded) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isLoading, id, router, setCurrentDocument, stop]);
|
}, [isLoading, id, setCurrentDocument, stop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDocument();
|
loadDocument();
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,8 @@ import {
|
||||||
ensureParsedPdfDocumentOperation,
|
ensureParsedPdfDocumentOperation,
|
||||||
forceReparsePdfDocument,
|
forceReparsePdfDocument,
|
||||||
getDocumentMetadata,
|
getDocumentMetadata,
|
||||||
getDocumentSettings,
|
|
||||||
getParsedPdfDocument,
|
getParsedPdfDocument,
|
||||||
ParsedPdfNotReadyError,
|
ParsedPdfNotReadyError,
|
||||||
putDocumentSettings,
|
|
||||||
subscribeParsedPdfDocumentEvents,
|
subscribeParsedPdfDocumentEvents,
|
||||||
} from '@/lib/client/api/documents';
|
} from '@/lib/client/api/documents';
|
||||||
import { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf';
|
import { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf';
|
||||||
|
|
@ -56,6 +54,16 @@ import type {
|
||||||
} from '@/types/tts';
|
} from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
||||||
import { clampSegmentPreloadDepth } from '@/types/config';
|
import { clampSegmentPreloadDepth } from '@/types/config';
|
||||||
|
import { useDocumentSettings } from '@/hooks/useDocumentSettings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a `setCurrentDocument` call.
|
||||||
|
* - `loaded`: the document was fetched and is now the active document.
|
||||||
|
* - `superseded`: the load was aborted/replaced by a newer load (or unmount).
|
||||||
|
* A newer load is authoritative; callers must NOT treat this as an error.
|
||||||
|
* - `failed`: a genuine failure (not found, wrong type, network error).
|
||||||
|
*/
|
||||||
|
export type SetCurrentDocumentResult = 'loaded' | 'superseded' | 'failed';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface defining all available methods and properties for the PDF route.
|
* Interface defining all available methods and properties for the PDF route.
|
||||||
|
|
@ -78,7 +86,7 @@ export interface PdfDocumentState {
|
||||||
parsedOverlayEnabled: boolean;
|
parsedOverlayEnabled: boolean;
|
||||||
setParsedOverlayEnabled: (enabled: boolean) => void;
|
setParsedOverlayEnabled: (enabled: boolean) => void;
|
||||||
forceReparseParsedPdf: () => Promise<void>;
|
forceReparseParsedPdf: () => Promise<void>;
|
||||||
setCurrentDocument: (id: string) => Promise<boolean>;
|
setCurrentDocument: (id: string) => Promise<SetCurrentDocumentResult>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
|
|
||||||
// PDF functionality
|
// PDF functionality
|
||||||
|
|
@ -139,8 +147,6 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
registerVisualPageChangeHandler,
|
registerVisualPageChangeHandler,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
const {
|
const {
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
providerRef,
|
providerRef,
|
||||||
segmentPreloadDepthPages,
|
segmentPreloadDepthPages,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
|
|
@ -158,6 +164,11 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
const [parseProgress, setParseProgress] = useState<PdfParseProgress | null>(null);
|
const [parseProgress, setParseProgress] = useState<PdfParseProgress | null>(null);
|
||||||
const [, setActiveParseOpId] = useState<string | null>(null);
|
const [, setActiveParseOpId] = useState<string | null>(null);
|
||||||
const [documentSettings, setDocumentSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
|
const [documentSettings, setDocumentSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
|
||||||
|
const serverDocumentSettings = useDocumentSettings(currDocId);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serverDocumentSettings.query.data?.settings) return;
|
||||||
|
setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, serverDocumentSettings.query.data.settings));
|
||||||
|
}, [serverDocumentSettings.query.data?.settings]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDocumentLanguage(documentSettings.language ?? 'auto');
|
setDocumentLanguage(documentSettings.language ?? 'auto');
|
||||||
lastPreparedPlaybackPageRef.current = null;
|
lastPreparedPlaybackPageRef.current = null;
|
||||||
|
|
@ -206,17 +217,6 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
pageTextCacheRef.current.clear();
|
pageTextCacheRef.current.clear();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchDocumentSettings = useCallback(async (documentId: string, signal: AbortSignal): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const response = await getDocumentSettings(documentId, { signal });
|
|
||||||
setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
|
||||||
console.warn('Failed to load document settings, using defaults:', error);
|
|
||||||
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startParsedEventStream = useCallback((documentId: string, initialOpId: string) => {
|
const startParsedEventStream = useCallback((documentId: string, initialOpId: string) => {
|
||||||
parseStreamAbortRef.current?.abort();
|
parseStreamAbortRef.current?.abort();
|
||||||
parseSseCloseRef.current?.();
|
parseSseCloseRef.current?.();
|
||||||
|
|
@ -516,12 +516,12 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the current document based on its ID
|
* Sets the current document based on its ID
|
||||||
* Retrieves document from IndexedDB
|
* Retrieves document from server metadata and the browser blob cache.
|
||||||
*
|
*
|
||||||
* @param {string} id - The unique identifier of the document to set
|
* @param {string} id - The unique identifier of the document to set
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const setCurrentDocument = useCallback(async (id: string): Promise<boolean> => {
|
const setCurrentDocument = useCallback(async (id: string): Promise<SetCurrentDocumentResult> => {
|
||||||
// --- race-condition guard ---
|
// --- race-condition guard ---
|
||||||
const seq = ++docLoadSeqRef.current;
|
const seq = ++docLoadSeqRef.current;
|
||||||
docLoadAbortRef.current?.abort();
|
docLoadAbortRef.current?.abort();
|
||||||
|
|
@ -554,13 +554,12 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS);
|
||||||
|
|
||||||
const meta = await getDocumentMetadata(id, { signal: controller.signal });
|
const meta = await getDocumentMetadata(id, { signal: controller.signal });
|
||||||
if (seq !== docLoadSeqRef.current) return false; // stale
|
if (seq !== docLoadSeqRef.current) return 'superseded'; // a newer load took over
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
console.error('Document not found on server');
|
console.error('Document not found on server');
|
||||||
return false;
|
return 'failed';
|
||||||
}
|
}
|
||||||
if (meta.type === 'pdf') {
|
if (meta.type === 'pdf') {
|
||||||
void fetchDocumentSettings(id, controller.signal);
|
|
||||||
void resolveParsedDocumentState(id, controller.signal).catch((error) => {
|
void resolveParsedDocumentState(id, controller.signal).catch((error) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
console.error('Failed to resolve parsed PDF state:', error);
|
console.error('Failed to resolve parsed PDF state:', error);
|
||||||
|
|
@ -568,28 +567,29 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await ensureCachedDocument(meta, { signal: controller.signal });
|
const doc = await ensureCachedDocument(meta, { signal: controller.signal });
|
||||||
if (seq !== docLoadSeqRef.current) return false; // stale
|
if (seq !== docLoadSeqRef.current) return 'superseded'; // a newer load took over
|
||||||
if (doc.type !== 'pdf') {
|
if (doc.type !== 'pdf') {
|
||||||
console.error('Document is not a PDF');
|
console.error('Document is not a PDF');
|
||||||
return false;
|
return 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrDocName(doc.name);
|
setCurrDocName(doc.name);
|
||||||
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
|
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
|
||||||
// buffer passed into the worker; we always pass clones to react-pdf.
|
// buffer passed into the worker; we always pass clones to react-pdf.
|
||||||
setCurrDocData(doc.data.slice(0));
|
setCurrDocData(doc.data.slice(0));
|
||||||
return true;
|
return 'loaded';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') return false;
|
// An aborted load means a newer selection (or unmount) took over; not a failure.
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') return 'superseded';
|
||||||
|
if (controller.signal.aborted) return 'superseded';
|
||||||
console.error('Failed to get document:', error);
|
console.error('Failed to get document:', error);
|
||||||
return false;
|
return 'failed';
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
|
// Clean up the controller only if it's still ours (a newer call hasn't replaced it).
|
||||||
if (docLoadAbortRef.current === controller) {
|
if (docLoadAbortRef.current === controller) {
|
||||||
docLoadAbortRef.current = null;
|
docLoadAbortRef.current = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}, [
|
}, [
|
||||||
setCurrDocId,
|
setCurrDocId,
|
||||||
setCurrDocName,
|
setCurrDocName,
|
||||||
|
|
@ -597,7 +597,6 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
setCurrDocPages,
|
setCurrDocPages,
|
||||||
setCurrDocText,
|
setCurrDocText,
|
||||||
setPdfDocument,
|
setPdfDocument,
|
||||||
fetchDocumentSettings,
|
|
||||||
resolveParsedDocumentState,
|
resolveParsedDocumentState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -605,12 +604,11 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
if (!currDocId) return;
|
if (!currDocId) return;
|
||||||
setDocumentSettings(settings);
|
setDocumentSettings(settings);
|
||||||
try {
|
try {
|
||||||
const updated = await putDocumentSettings(currDocId, settings);
|
await serverDocumentSettings.mutation.mutateAsync(settings);
|
||||||
setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, updated.settings));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to persist document settings:', error);
|
console.warn('Failed to persist document settings:', error);
|
||||||
}
|
}
|
||||||
}, [currDocId]);
|
}, [currDocId, serverDocumentSettings.mutation]);
|
||||||
|
|
||||||
const forceReparseParsedPdf = useCallback(async (): Promise<void> => {
|
const forceReparseParsedPdf = useCallback(async (): Promise<void> => {
|
||||||
if (!currDocId) return;
|
if (!currDocId) return;
|
||||||
|
|
@ -684,8 +682,6 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
try {
|
try {
|
||||||
return await runAudiobookGeneration({
|
return await runAudiobookGeneration({
|
||||||
adapter: audiobookAdapter,
|
adapter: audiobookAdapter,
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
defaultProvider: providerRef,
|
defaultProvider: providerRef,
|
||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
|
|
@ -698,7 +694,7 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
}, [audiobookAdapter, providerRef]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regenerates a specific chapter (page) of the PDF audiobook
|
* Regenerates a specific chapter (page) of the PDF audiobook
|
||||||
|
|
@ -717,8 +713,6 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
bookId,
|
bookId,
|
||||||
format,
|
format,
|
||||||
signal,
|
signal,
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
defaultProvider: providerRef,
|
defaultProvider: providerRef,
|
||||||
settings,
|
settings,
|
||||||
});
|
});
|
||||||
|
|
@ -729,7 +723,7 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
console.error('Error regenerating page:', error);
|
console.error('Error regenerating page:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
}, [audiobookAdapter, providerRef]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook to initialize TTS as non-EPUB mode
|
* Effect hook to initialize TTS as non-EPUB mode
|
||||||
|
|
|
||||||
|
|
@ -477,10 +477,7 @@ export async function POST(request: NextRequest) {
|
||||||
providerForError = requestedProvider;
|
providerForError = requestedProvider;
|
||||||
const credResolved = await resolveTtsCredentials({
|
const credResolved = await resolveTtsCredentials({
|
||||||
providerHeader: requestedProvider,
|
providerHeader: requestedProvider,
|
||||||
apiKeyHeader: request.headers.get('x-openai-key'),
|
|
||||||
baseUrlHeader: request.headers.get('x-openai-base-url'),
|
|
||||||
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
||||||
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
|
|
||||||
});
|
});
|
||||||
if ('error' in credResolved) {
|
if ('error' in credResolved) {
|
||||||
if (credResolved.error === 'no_shared_provider_configured') {
|
if (credResolved.error === 'no_shared_provider_configured') {
|
||||||
|
|
|
||||||
27
src/app/api/documents/[id]/opened/route.ts
Normal file
27
src/app/api/documents/[id]/opened/route.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@openreader/database';
|
||||||
|
import { documents } from '@openreader/database/schema';
|
||||||
|
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
|
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const scope = await resolveUserStateScope(req);
|
||||||
|
if (scope instanceof Response) return scope;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const recentlyOpenedAt = nowTimestampMs();
|
||||||
|
const rows = await db.update(documents).set({ recentlyOpenedAt }).where(and(
|
||||||
|
eq(documents.userId, scope.ownerUserId),
|
||||||
|
eq(documents.id, id.toLowerCase()),
|
||||||
|
)).returning({ id: documents.id });
|
||||||
|
if (!rows[0]) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
return NextResponse.json({ documentId: rows[0].id, recentlyOpenedAt });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, {
|
||||||
|
apiErrorMessage: 'Failed to update recently opened state',
|
||||||
|
normalize: { code: 'DOCUMENT_OPENED_UPDATE_FAILED', errorClass: 'db' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,7 @@ export async function GET(req: NextRequest) {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
presignUrl,
|
presignUrl,
|
||||||
fallbackUrl,
|
fallbackUrl,
|
||||||
|
previewVersion: preview.eTag || String(doc.lastModified),
|
||||||
...(directUrl ? { directUrl } : {}),
|
...(directUrl ? { directUrl } : {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
43
src/app/api/documents/folders/route.ts
Normal file
43
src/app/api/documents/folders/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@openreader/database';
|
||||||
|
import { documents, userFolders } from '@openreader/database/schema';
|
||||||
|
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const scope = await resolveUserStateScope(req);
|
||||||
|
if (scope instanceof Response) return scope;
|
||||||
|
const body = (await req.json().catch(() => null)) as { documentIds?: unknown; folderId?: unknown } | null;
|
||||||
|
const documentIds = Array.isArray(body?.documentIds)
|
||||||
|
? Array.from(new Set(body.documentIds.filter((id): id is string => typeof id === 'string')))
|
||||||
|
: [];
|
||||||
|
const folderId = body?.folderId === null ? null : typeof body?.folderId === 'string' ? body.folderId : undefined;
|
||||||
|
if (documentIds.length === 0 || folderId === undefined) {
|
||||||
|
return NextResponse.json({ error: 'documentIds and folderId are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const owned = await db.select({ id: documents.id }).from(documents).where(and(
|
||||||
|
eq(documents.userId, scope.ownerUserId),
|
||||||
|
inArray(documents.id, documentIds),
|
||||||
|
));
|
||||||
|
if (owned.length !== documentIds.length) return NextResponse.json({ error: 'One or more documents were not found' }, { status: 404 });
|
||||||
|
if (folderId) {
|
||||||
|
const folder = await db.select({ id: userFolders.id }).from(userFolders).where(and(
|
||||||
|
eq(userFolders.userId, scope.ownerUserId),
|
||||||
|
eq(userFolders.id, folderId),
|
||||||
|
)).limit(1);
|
||||||
|
if (!folder[0]) return NextResponse.json({ error: 'Folder not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
await db.update(documents).set({ folderId }).where(and(
|
||||||
|
eq(documents.userId, scope.ownerUserId),
|
||||||
|
inArray(documents.id, documentIds),
|
||||||
|
));
|
||||||
|
return NextResponse.json({ success: true, documentIds, folderId });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, {
|
||||||
|
apiErrorMessage: 'Failed to move documents',
|
||||||
|
normalize: { code: 'DOCUMENT_FOLDER_UPDATE_FAILED', errorClass: 'db' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,8 @@ export async function GET(req: NextRequest) {
|
||||||
size: number;
|
size: number;
|
||||||
lastModified: number;
|
lastModified: number;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
folderId: string | null;
|
||||||
|
recentlyOpenedAt: number | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const results: BaseDocument[] = rows.map((doc) => {
|
const results: BaseDocument[] = rows.map((doc) => {
|
||||||
|
|
@ -75,6 +77,9 @@ export async function GET(req: NextRequest) {
|
||||||
lastModified: Number(doc.lastModified),
|
lastModified: Number(doc.lastModified),
|
||||||
type,
|
type,
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
|
folderId: doc.folderId ?? undefined,
|
||||||
|
recentlyOpenedAt: doc.recentlyOpenedAt == null ? undefined : Number(doc.recentlyOpenedAt),
|
||||||
|
contentVersion: doc.id,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
52
src/app/api/folders/[id]/route.ts
Normal file
52
src/app/api/folders/[id]/route.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@openreader/database';
|
||||||
|
import { documents, userFolders } from '@openreader/database/schema';
|
||||||
|
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
|
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const scope = await resolveUserStateScope(req);
|
||||||
|
if (scope instanceof Response) return scope;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const body = (await req.json().catch(() => null)) as { name?: unknown; position?: unknown } | null;
|
||||||
|
const name = typeof body?.name === 'string' ? body.name.trim().slice(0, 200) : undefined;
|
||||||
|
const position = Number.isFinite(body?.position) ? Number(body?.position) : undefined;
|
||||||
|
if (!name && position === undefined) return NextResponse.json({ error: 'No valid fields provided' }, { status: 400 });
|
||||||
|
const rows = await db.update(userFolders).set({
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
...(position !== undefined ? { position } : {}),
|
||||||
|
updatedAt: nowTimestampMs(),
|
||||||
|
}).where(and(eq(userFolders.id, id), eq(userFolders.userId, scope.ownerUserId))).returning();
|
||||||
|
if (!rows[0]) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
return NextResponse.json({ folder: rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, {
|
||||||
|
apiErrorMessage: 'Failed to update folder',
|
||||||
|
normalize: { code: 'FOLDER_UPDATE_FAILED', errorClass: 'db' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const scope = await resolveUserStateScope(req);
|
||||||
|
if (scope instanceof Response) return scope;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
await db.update(documents).set({ folderId: null }).where(and(
|
||||||
|
eq(documents.userId, scope.ownerUserId),
|
||||||
|
eq(documents.folderId, id),
|
||||||
|
));
|
||||||
|
await db.delete(userFolders).where(and(eq(userFolders.id, id), eq(userFolders.userId, scope.ownerUserId)));
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, {
|
||||||
|
apiErrorMessage: 'Failed to delete folder',
|
||||||
|
normalize: { code: 'FOLDER_DELETE_FAILED', errorClass: 'db' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/app/api/folders/route.ts
Normal file
81
src/app/api/folders/route.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { and, asc, eq, inArray } from 'drizzle-orm';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@openreader/database';
|
||||||
|
import { documents, userFolders } from '@openreader/database/schema';
|
||||||
|
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
|
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const scope = await resolveUserStateScope(req);
|
||||||
|
if (scope instanceof Response) return scope;
|
||||||
|
const rows = await db.select().from(userFolders)
|
||||||
|
.where(eq(userFolders.userId, scope.ownerUserId))
|
||||||
|
.orderBy(asc(userFolders.position), asc(userFolders.createdAt));
|
||||||
|
return NextResponse.json({ folders: rows });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, {
|
||||||
|
apiErrorMessage: 'Failed to load folders',
|
||||||
|
normalize: { code: 'FOLDERS_LOAD_FAILED', errorClass: 'db' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const scope = await resolveUserStateScope(req);
|
||||||
|
if (scope instanceof Response) return scope;
|
||||||
|
const body = (await req.json().catch(() => null)) as { id?: unknown; name?: unknown; documentIds?: unknown } | null;
|
||||||
|
const name = typeof body?.name === 'string' ? body.name.trim().slice(0, 200) : '';
|
||||||
|
if (!name) return NextResponse.json({ error: 'Folder name is required' }, { status: 400 });
|
||||||
|
const documentIds = Array.isArray(body?.documentIds)
|
||||||
|
? Array.from(new Set(body.documentIds.filter((id): id is string => typeof id === 'string')))
|
||||||
|
: [];
|
||||||
|
if (documentIds.length > 0) {
|
||||||
|
const owned = await db.select({ id: documents.id }).from(documents).where(and(
|
||||||
|
eq(documents.userId, scope.ownerUserId),
|
||||||
|
inArray(documents.id, documentIds),
|
||||||
|
));
|
||||||
|
if (owned.length !== documentIds.length) {
|
||||||
|
return NextResponse.json({ error: 'One or more documents were not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const id = typeof body?.id === 'string' && /^[a-zA-Z0-9-]{1,100}$/.test(body.id)
|
||||||
|
? body.id
|
||||||
|
: crypto.randomUUID();
|
||||||
|
const now = nowTimestampMs();
|
||||||
|
await db.transaction(async (tx: typeof db) => {
|
||||||
|
await tx.insert(userFolders).values({ id, userId: scope.ownerUserId, name, position: now, createdAt: now, updatedAt: now });
|
||||||
|
if (documentIds.length > 0) {
|
||||||
|
await tx.update(documents).set({ folderId: id }).where(and(
|
||||||
|
eq(documents.userId, scope.ownerUserId),
|
||||||
|
inArray(documents.id, documentIds),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return NextResponse.json({ folder: { id, userId: scope.ownerUserId, name, position: now, createdAt: now, updatedAt: now } });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, {
|
||||||
|
apiErrorMessage: 'Failed to create folder',
|
||||||
|
normalize: { code: 'FOLDER_CREATE_FAILED', errorClass: 'db' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const scope = await resolveUserStateScope(req);
|
||||||
|
if (scope instanceof Response) return scope;
|
||||||
|
await db.update(documents).set({ folderId: null }).where(eq(documents.userId, scope.ownerUserId));
|
||||||
|
await db.delete(userFolders).where(eq(userFolders.userId, scope.ownerUserId));
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, {
|
||||||
|
apiErrorMessage: 'Failed to delete folders',
|
||||||
|
normalize: { code: 'FOLDERS_DELETE_FAILED', errorClass: 'db' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -183,10 +183,7 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
const requestCreds = await resolveTtsCredentials({
|
const requestCreds = await resolveTtsCredentials({
|
||||||
providerHeader: parsed.settings.providerRef,
|
providerHeader: parsed.settings.providerRef,
|
||||||
apiKeyHeader: request.headers.get('x-openai-key'),
|
|
||||||
baseUrlHeader: request.headers.get('x-openai-base-url'),
|
|
||||||
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
||||||
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
|
|
||||||
});
|
});
|
||||||
if ('error' in requestCreds) {
|
if ('error' in requestCreds) {
|
||||||
const status = requestCreds.error === 'no_shared_provider_configured' ? 503 : 404;
|
const status = requestCreds.error === 'no_shared_provider_configured' ? 503 : 404;
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,7 @@ export async function GET(req: NextRequest) {
|
||||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||||
const resolved = await resolveTtsCredentials({
|
const resolved = await resolveTtsCredentials({
|
||||||
providerHeader: req.headers.get('x-tts-provider'),
|
providerHeader: req.headers.get('x-tts-provider'),
|
||||||
apiKeyHeader: req.headers.get('x-openai-key'),
|
|
||||||
baseUrlHeader: req.headers.get('x-openai-base-url'),
|
|
||||||
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
||||||
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if ('error' in resolved) {
|
if ('error' in resolved) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { claimAnonymousData } from '@/lib/server/user/claim-data';
|
import { claimAnonymousData } from '@/lib/server/user/claim-data';
|
||||||
import { auth } from '@/lib/server/auth/auth';
|
import { auth } from '@/lib/server/auth/auth';
|
||||||
import { db } from '@openreader/database';
|
import { db } from '@openreader/database';
|
||||||
import { audiobooks, documentSettings, documents, userDocumentProgress, userPreferences } from '@openreader/database/schema';
|
import { audiobooks, documentSettings, documents, userDocumentProgress, userFolders, userOnboarding, userPreferences } from '@openreader/database/schema';
|
||||||
import { count, eq, ne } from 'drizzle-orm';
|
import { count, eq, ne } from 'drizzle-orm';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
|
@ -26,14 +26,16 @@ async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
|
||||||
|
|
||||||
async function getClaimableCounts(
|
async function getClaimableCounts(
|
||||||
unclaimedUserId: string,
|
unclaimedUserId: string,
|
||||||
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number }> {
|
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number; folders: number; onboarding: number }> {
|
||||||
const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount]] =
|
const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount], [folderCount], [onboardingCount]] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)),
|
db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)),
|
||||||
db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)),
|
db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)),
|
||||||
db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)),
|
db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)),
|
||||||
db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)),
|
db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)),
|
||||||
db.select({ count: count() }).from(documentSettings).where(eq(documentSettings.userId, unclaimedUserId)),
|
db.select({ count: count() }).from(documentSettings).where(eq(documentSettings.userId, unclaimedUserId)),
|
||||||
|
db.select({ count: count() }).from(userFolders).where(eq(userFolders.userId, unclaimedUserId)),
|
||||||
|
db.select({ count: count() }).from(userOnboarding).where(eq(userOnboarding.userId, unclaimedUserId)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -42,6 +44,8 @@ async function getClaimableCounts(
|
||||||
preferences: Number(preferencesCount?.count ?? 0),
|
preferences: Number(preferencesCount?.count ?? 0),
|
||||||
progress: Number(progressCount?.count ?? 0),
|
progress: Number(progressCount?.count ?? 0),
|
||||||
documentSettings: Number(settingsCount?.count ?? 0),
|
documentSettings: Number(settingsCount?.count ?? 0),
|
||||||
|
folders: Number(folderCount?.count ?? 0),
|
||||||
|
onboarding: Number(onboardingCount?.count ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import {
|
||||||
userDocumentProgress,
|
userDocumentProgress,
|
||||||
userJobEvents,
|
userJobEvents,
|
||||||
userPreferences,
|
userPreferences,
|
||||||
|
userFolders,
|
||||||
|
userOnboarding,
|
||||||
userTtsChars,
|
userTtsChars,
|
||||||
} from '@openreader/database/schema';
|
} from '@openreader/database/schema';
|
||||||
import * as authSchemaSqlite from '@openreader/database/schema-auth-sqlite';
|
import * as authSchemaSqlite from '@openreader/database/schema-auth-sqlite';
|
||||||
|
|
@ -65,6 +67,8 @@ export async function GET(req: NextRequest) {
|
||||||
segmentVariants,
|
segmentVariants,
|
||||||
userDocs,
|
userDocs,
|
||||||
userAudiobooks,
|
userAudiobooks,
|
||||||
|
folders,
|
||||||
|
onboarding,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1),
|
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1),
|
||||||
db
|
db
|
||||||
|
|
@ -107,6 +111,8 @@ export async function GET(req: NextRequest) {
|
||||||
.from(audiobooks)
|
.from(audiobooks)
|
||||||
.where(eq(audiobooks.userId, userId))
|
.where(eq(audiobooks.userId, userId))
|
||||||
.orderBy(desc(audiobooks.createdAt)),
|
.orderBy(desc(audiobooks.createdAt)),
|
||||||
|
db.select().from(userFolders).where(eq(userFolders.userId, userId)).orderBy(userFolders.position),
|
||||||
|
db.select().from(userOnboarding).where(eq(userOnboarding.userId, userId)).limit(1),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
|
const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
|
||||||
|
|
@ -190,6 +196,8 @@ export async function GET(req: NextRequest) {
|
||||||
exportedAtMs,
|
exportedAtMs,
|
||||||
profileData,
|
profileData,
|
||||||
preferences: prefs[0] ?? null,
|
preferences: prefs[0] ?? null,
|
||||||
|
folders,
|
||||||
|
onboarding: onboarding[0] ?? null,
|
||||||
readingHistory: progress,
|
readingHistory: progress,
|
||||||
ttsUsage,
|
ttsUsage,
|
||||||
jobEvents,
|
jobEvents,
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,15 @@
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@openreader/database';
|
import { db } from '@openreader/database';
|
||||||
import { userPreferences } from '@openreader/database/schema';
|
import { userOnboarding } from '@openreader/database/schema';
|
||||||
import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog';
|
import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog';
|
||||||
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
import {
|
|
||||||
deserializeUserPreferencesPayload,
|
|
||||||
extractUserPreferencesMeta,
|
|
||||||
withUserPreferencesMeta,
|
|
||||||
USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY,
|
|
||||||
} from '@/lib/server/user/preferences-payload';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
function serializePreferencesForDb(payload: Record<string, unknown>): Record<string, unknown> | string {
|
|
||||||
if (process.env.POSTGRES_URL) return payload;
|
|
||||||
return JSON.stringify(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const scope = await resolveUserStateScope(req);
|
const scope = await resolveUserStateScope(req);
|
||||||
|
|
@ -38,40 +27,28 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
dataJson: userPreferences.dataJson,
|
lastSeenAppVersion: userOnboarding.lastSeenAppVersion,
|
||||||
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
|
|
||||||
})
|
})
|
||||||
.from(userPreferences)
|
.from(userOnboarding)
|
||||||
.where(eq(userPreferences.userId, scope.ownerUserId))
|
.where(eq(userOnboarding.userId, scope.ownerUserId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
const existingPayload = deserializeUserPreferencesPayload(row?.dataJson);
|
const lastSeenVersion = row?.lastSeenAppVersion ?? null;
|
||||||
const existingMeta = extractUserPreferencesMeta(existingPayload);
|
|
||||||
const lastSeenVersion = typeof existingMeta.lastSeenAppVersion === 'string'
|
|
||||||
? existingMeta.lastSeenAppVersion
|
|
||||||
: null;
|
|
||||||
const shouldOpen = shouldOpenChangelogForVersionChange(lastSeenVersion, currentVersion);
|
const shouldOpen = shouldOpenChangelogForVersionChange(lastSeenVersion, currentVersion);
|
||||||
const nextMeta = {
|
|
||||||
...existingMeta,
|
|
||||||
[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY]: currentVersion,
|
|
||||||
};
|
|
||||||
const dataJson = serializePreferencesForDb(withUserPreferencesMeta(existingPayload, nextMeta));
|
|
||||||
const updatedAt = nowTimestampMs();
|
const updatedAt = nowTimestampMs();
|
||||||
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(userPreferences)
|
.insert(userOnboarding)
|
||||||
.values({
|
.values({
|
||||||
userId: scope.ownerUserId,
|
userId: scope.ownerUserId,
|
||||||
dataJson,
|
lastSeenAppVersion: currentVersion,
|
||||||
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
|
|
||||||
updatedAt,
|
updatedAt,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [userPreferences.userId],
|
target: [userOnboarding.userId],
|
||||||
set: {
|
set: {
|
||||||
dataJson,
|
lastSeenAppVersion: currentVersion,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
78
src/app/api/user/state/onboarding/route.ts
Normal file
78
src/app/api/user/state/onboarding/route.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@openreader/database';
|
||||||
|
import { userOnboarding } from '@openreader/database/schema';
|
||||||
|
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
|
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const scope = await resolveUserStateScope(req);
|
||||||
|
if (scope instanceof Response) return scope;
|
||||||
|
const rows = await db.select().from(userOnboarding)
|
||||||
|
.where(eq(userOnboarding.userId, scope.ownerUserId)).limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
return NextResponse.json({
|
||||||
|
onboarding: {
|
||||||
|
privacyAcceptedAtMs: row?.privacyAcceptedAtMs == null ? null : Number(row.privacyAcceptedAtMs),
|
||||||
|
lastSeenAppVersion: row?.lastSeenAppVersion ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, {
|
||||||
|
apiErrorMessage: 'Failed to load onboarding state',
|
||||||
|
normalize: { code: 'USER_ONBOARDING_LOAD_FAILED', errorClass: 'db' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const scope = await resolveUserStateScope(req);
|
||||||
|
if (scope instanceof Response) return scope;
|
||||||
|
const body = (await req.json().catch(() => null)) as {
|
||||||
|
privacyAccepted?: unknown;
|
||||||
|
lastSeenAppVersion?: unknown;
|
||||||
|
} | null;
|
||||||
|
const now = nowTimestampMs();
|
||||||
|
const privacyAcceptedAtMs = body?.privacyAccepted === true
|
||||||
|
? now
|
||||||
|
: body?.privacyAccepted === false ? null : undefined;
|
||||||
|
const lastSeenAppVersion = typeof body?.lastSeenAppVersion === 'string'
|
||||||
|
? body.lastSeenAppVersion.trim() || null
|
||||||
|
: undefined;
|
||||||
|
if (privacyAcceptedAtMs === undefined && lastSeenAppVersion === undefined) {
|
||||||
|
return NextResponse.json({ error: 'No valid onboarding fields provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
await db.insert(userOnboarding).values({
|
||||||
|
userId: scope.ownerUserId,
|
||||||
|
privacyAcceptedAtMs: privacyAcceptedAtMs ?? null,
|
||||||
|
lastSeenAppVersion: lastSeenAppVersion ?? null,
|
||||||
|
updatedAt: now,
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [userOnboarding.userId],
|
||||||
|
set: {
|
||||||
|
...(privacyAcceptedAtMs !== undefined ? { privacyAcceptedAtMs } : {}),
|
||||||
|
...(lastSeenAppVersion !== undefined ? { lastSeenAppVersion } : {}),
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rows = await db.select().from(userOnboarding)
|
||||||
|
.where(eq(userOnboarding.userId, scope.ownerUserId)).limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
return NextResponse.json({
|
||||||
|
onboarding: {
|
||||||
|
privacyAcceptedAtMs: row?.privacyAcceptedAtMs == null ? null : Number(row.privacyAcceptedAtMs),
|
||||||
|
lastSeenAppVersion: row?.lastSeenAppVersion ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, {
|
||||||
|
apiErrorMessage: 'Failed to update onboarding state',
|
||||||
|
normalize: { code: 'USER_ONBOARDING_UPDATE_FAILED', errorClass: 'db' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,12 +13,6 @@ import {
|
||||||
sanitizePreferencesPatch,
|
sanitizePreferencesPatch,
|
||||||
type PreferenceNormalizationContext,
|
type PreferenceNormalizationContext,
|
||||||
} from '@/lib/server/user/preferences-normalize';
|
} from '@/lib/server/user/preferences-normalize';
|
||||||
import {
|
|
||||||
deserializeUserPreferencesPayload,
|
|
||||||
extractUserPreferencesMeta,
|
|
||||||
withUserPreferencesMeta,
|
|
||||||
type UserPreferencesMeta,
|
|
||||||
} from '@/lib/server/user/preferences-payload';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -27,6 +21,22 @@ function serializePreferencesForDb(payload: Record<string, unknown>): Record<str
|
||||||
return JSON.stringify(payload);
|
return JSON.stringify(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deserializePreferences(value: unknown): Record<string, unknown> {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? parsed as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
|
async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
|
||||||
const [runtimeConfig, providers] = await Promise.all([
|
const [runtimeConfig, providers] = await Promise.all([
|
||||||
getResolvedRuntimeConfig(),
|
getResolvedRuntimeConfig(),
|
||||||
|
|
@ -49,11 +59,10 @@ async function loadPreferenceNormalizationContext(): Promise<PreferenceNormaliza
|
||||||
function parseStoredPreferences(
|
function parseStoredPreferences(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
context: PreferenceNormalizationContext,
|
context: PreferenceNormalizationContext,
|
||||||
): { patch: SyncedPreferencesPatch; migrated: boolean; meta: UserPreferencesMeta } {
|
): { patch: SyncedPreferencesPatch; migrated: boolean } {
|
||||||
const payload = deserializeUserPreferencesPayload(value);
|
const payload = deserializePreferences(value);
|
||||||
const meta = extractUserPreferencesMeta(payload);
|
|
||||||
const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true });
|
const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true });
|
||||||
return { ...sanitized, meta };
|
return { ...sanitized, migrated: sanitized.migrated || '_meta' in payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeClientUpdatedAtMs(value: unknown): number {
|
function normalizeClientUpdatedAtMs(value: unknown): number {
|
||||||
|
|
@ -80,7 +89,6 @@ export async function GET(req: NextRequest) {
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
const stored = parseStoredPreferences(row?.dataJson, normalizationContext);
|
const stored = parseStoredPreferences(row?.dataJson, normalizationContext);
|
||||||
const storedPatch = stored.patch;
|
const storedPatch = stored.patch;
|
||||||
const storedPayload = withUserPreferencesMeta(storedPatch, stored.meta);
|
|
||||||
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
|
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
|
||||||
|
|
||||||
if (row && stored.migrated) {
|
if (row && stored.migrated) {
|
||||||
|
|
@ -89,14 +97,14 @@ export async function GET(req: NextRequest) {
|
||||||
.insert(userPreferences)
|
.insert(userPreferences)
|
||||||
.values({
|
.values({
|
||||||
userId: scope.ownerUserId,
|
userId: scope.ownerUserId,
|
||||||
dataJson: serializePreferencesForDb(storedPayload),
|
dataJson: serializePreferencesForDb(storedPatch),
|
||||||
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
|
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [userPreferences.userId],
|
target: [userPreferences.userId],
|
||||||
set: {
|
set: {
|
||||||
dataJson: serializePreferencesForDb(storedPayload),
|
dataJson: serializePreferencesForDb(storedPatch),
|
||||||
updatedAt,
|
updatedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -161,8 +169,7 @@ export async function PUT(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedPatch = { ...existingPatch, ...patch };
|
const mergedPatch = { ...existingPatch, ...patch };
|
||||||
const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta);
|
const dataJson = serializePreferencesForDb(mergedPatch);
|
||||||
const dataJson = serializePreferencesForDb(payloadWithMeta);
|
|
||||||
const updatedAt = nowTimestampMs();
|
const updatedAt = nowTimestampMs();
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode, useState } from 'react';
|
import { ReactNode, useEffect, useState } from 'react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||||
|
|
@ -25,6 +25,15 @@ export function Providers({ children, authBaseUrl, allowAnonymousAuthSessions, g
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof indexedDB === 'undefined') return;
|
||||||
|
try {
|
||||||
|
indexedDB.deleteDatabase('openreader-db');
|
||||||
|
} catch {
|
||||||
|
// Legacy IndexedDB cleanup is best effort and never blocks startup.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RuntimeConfigProvider>
|
<RuntimeConfigProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { updateAppConfig } from '@/lib/client/dexie';
|
import { useOnboardingState } from '@/hooks/useOnboardingState';
|
||||||
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
|
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
|
||||||
|
|
||||||
interface PrivacyModalProps {
|
interface PrivacyModalProps {
|
||||||
|
|
@ -47,6 +47,7 @@ function PrivacyModalBody({ origin }: { origin: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) {
|
export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) {
|
||||||
|
const { mutation } = useOnboardingState();
|
||||||
const [origin, setOrigin] = useState('');
|
const [origin, setOrigin] = useState('');
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(false);
|
||||||
|
|
||||||
|
|
@ -62,7 +63,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleAccept = async () => {
|
const handleAccept = async () => {
|
||||||
await updateAppConfig({ privacyAccepted: true });
|
await mutation.mutateAsync({ privacyAccepted: true });
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.dispatchEvent(new Event('openreader:privacyAccepted'));
|
window.dispatchEvent(new Event('openreader:privacyAccepted'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,8 @@ import { mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
||||||
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
||||||
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
||||||
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
|
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
|
||||||
|
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
import {
|
import {
|
||||||
isBuiltInTtsProviderId,
|
|
||||||
type TtsProviderType,
|
|
||||||
} from '@/lib/shared/tts-provider-catalog';
|
|
||||||
import {
|
|
||||||
defaultBaseUrlForProviderType,
|
|
||||||
defaultModelForProviderType,
|
|
||||||
resolveProviderDefaults,
|
resolveProviderDefaults,
|
||||||
resolveEffectiveProviderType,
|
resolveEffectiveProviderType,
|
||||||
resolveTtsProviderModelPolicy,
|
resolveTtsProviderModelPolicy,
|
||||||
|
|
@ -38,8 +33,6 @@ import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
|
||||||
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
|
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
|
||||||
import { AdminTasksPanel } from '@/components/admin/AdminTasksPanel';
|
import { AdminTasksPanel } from '@/components/admin/AdminTasksPanel';
|
||||||
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
||||||
import { flushUserPreferencesSync } from '@/lib/client/api/user-state';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
|
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
|
||||||
import {
|
import {
|
||||||
SidebarDialog,
|
SidebarDialog,
|
||||||
|
|
@ -219,7 +212,6 @@ export function SettingsModal({
|
||||||
const runtimeConfig = useRuntimeConfig();
|
const runtimeConfig = useRuntimeConfig();
|
||||||
const showAllProviderModels = runtimeConfig.showAllProviderModels;
|
const showAllProviderModels = runtimeConfig.showAllProviderModels;
|
||||||
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
|
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
|
||||||
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
|
|
||||||
const isOpen = open;
|
const isOpen = open;
|
||||||
const setIsOpen = onOpenChange;
|
const setIsOpen = onOpenChange;
|
||||||
const [isChangelogOpen, setIsChangelogOpen] = useState(false);
|
const [isChangelogOpen, setIsChangelogOpen] = useState(false);
|
||||||
|
|
@ -228,10 +220,8 @@ export function SettingsModal({
|
||||||
const { theme, setTheme, applyCustomColors } = useTheme();
|
const { theme, setTheme, applyCustomColors } = useTheme();
|
||||||
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
|
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
|
||||||
const [isCustomExpanded, setIsCustomExpanded] = useState(false);
|
const [isCustomExpanded, setIsCustomExpanded] = useState(false);
|
||||||
const { apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
|
const { providerRef, providerType, ttsModel, ttsInstructions, updateConfigKey } = useConfig();
|
||||||
const { refreshDocuments } = useDocuments();
|
const { refreshDocuments } = useDocuments();
|
||||||
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
|
||||||
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
|
||||||
const [localProviderRef, setLocalProviderRef] = useState(providerRef);
|
const [localProviderRef, setLocalProviderRef] = useState(providerRef);
|
||||||
const [localProviderType, setLocalProviderType] = useState<TtsProviderType>(providerType);
|
const [localProviderType, setLocalProviderType] = useState<TtsProviderType>(providerType);
|
||||||
const [modelValue, setModelValue] = useState(ttsModel);
|
const [modelValue, setModelValue] = useState(ttsModel);
|
||||||
|
|
@ -279,13 +269,12 @@ export function SettingsModal({
|
||||||
} = useMemo(() => resolveTtsSettingsViewModel({
|
} = useMemo(() => resolveTtsSettingsViewModel({
|
||||||
providerRef: localProviderRef,
|
providerRef: localProviderRef,
|
||||||
providerType: localProviderType,
|
providerType: localProviderType,
|
||||||
apiKey: localApiKey,
|
|
||||||
modelValue,
|
modelValue,
|
||||||
customModelInput,
|
customModelInput,
|
||||||
showAllProviderModels,
|
showAllProviderModels,
|
||||||
sharedProviders,
|
sharedProviders,
|
||||||
allowBuiltInProviders: !restrictUserApiKeys,
|
allowBuiltInProviders: false,
|
||||||
}), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllProviderModels, sharedProviders, restrictUserApiKeys]);
|
}), [localProviderRef, localProviderType, modelValue, customModelInput, showAllProviderModels, sharedProviders]);
|
||||||
const isSharedSelected = Boolean(selectedSharedProvider);
|
const isSharedSelected = Boolean(selectedSharedProvider);
|
||||||
const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0];
|
const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0];
|
||||||
|
|
||||||
|
|
@ -301,13 +290,11 @@ export function SettingsModal({
|
||||||
// focus refetch in ConfigContext, or async shared-provider loading) must not
|
// focus refetch in ConfigContext, or async shared-provider loading) must not
|
||||||
// stomp their in-progress selection. On close, resetToCurrent re-syncs.
|
// stomp their in-progress selection. On close, resetToCurrent re-syncs.
|
||||||
if (isOpen) return;
|
if (isOpen) return;
|
||||||
setLocalApiKey(apiKey);
|
|
||||||
setLocalBaseUrl(baseUrl);
|
|
||||||
setLocalProviderRef(providerRef);
|
setLocalProviderRef(providerRef);
|
||||||
setLocalProviderType(providerType);
|
setLocalProviderType(providerType);
|
||||||
setModelValue(ttsModel);
|
setModelValue(ttsModel);
|
||||||
setLocalTTSInstructions(ttsInstructions);
|
setLocalTTSInstructions(ttsInstructions);
|
||||||
}, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
|
}, [isOpen, providerRef, providerType, ttsModel, ttsInstructions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
|
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
|
||||||
|
|
@ -331,17 +318,9 @@ export function SettingsModal({
|
||||||
setModelValue(shared.defaultModel);
|
setModelValue(shared.defaultModel);
|
||||||
}
|
}
|
||||||
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
|
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
|
||||||
setLocalApiKey('');
|
|
||||||
setLocalBaseUrl('');
|
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBuiltInTtsProviderId(fallback.providerType)) {
|
|
||||||
setModelValue(defaultModelForProviderType(fallback.providerType));
|
|
||||||
setLocalBaseUrl(defaultBaseUrlForProviderType(fallback.providerType));
|
|
||||||
setCustomModelInput('');
|
|
||||||
}
|
|
||||||
}, [selectedProviderOption, ttsProviders, sharedProviders]);
|
}, [selectedProviderOption, ttsProviders, sharedProviders]);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
|
|
@ -459,19 +438,9 @@ export function SettingsModal({
|
||||||
setShowDeleteAccountConfirm(false);
|
setShowDeleteAccountConfirm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
|
|
||||||
if (type === 'apiKey') {
|
|
||||||
setLocalApiKey(value === '' ? '' : value);
|
|
||||||
} else if (type === 'baseUrl') {
|
|
||||||
setLocalBaseUrl(value === '' ? '' : value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetToCurrent = useCallback(() => {
|
const resetToCurrent = useCallback(() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setIsChangelogOpen(false);
|
setIsChangelogOpen(false);
|
||||||
setLocalApiKey(apiKey);
|
|
||||||
setLocalBaseUrl(baseUrl);
|
|
||||||
setLocalProviderRef(providerRef);
|
setLocalProviderRef(providerRef);
|
||||||
setLocalProviderType(providerType);
|
setLocalProviderType(providerType);
|
||||||
setModelValue(ttsModel);
|
setModelValue(ttsModel);
|
||||||
|
|
@ -481,7 +450,7 @@ export function SettingsModal({
|
||||||
} else {
|
} else {
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}
|
}
|
||||||
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
|
}, [providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
|
||||||
|
|
||||||
const [systemIsDark, setSystemIsDark] = useState(
|
const [systemIsDark, setSystemIsDark] = useState(
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
|
@ -546,13 +515,6 @@ export function SettingsModal({
|
||||||
model: modelValue,
|
model: modelValue,
|
||||||
sharedProviders,
|
sharedProviders,
|
||||||
});
|
});
|
||||||
const shouldShowBaseUrl = !restrictUserApiKeys
|
|
||||||
&& !isSharedSelected
|
|
||||||
&& providerModelPolicy.isResolvedProviderType
|
|
||||||
&& providerModelPolicy.providerType !== 'replicate'
|
|
||||||
&& providerModelPolicy.providerType !== 'speech-sdk'
|
|
||||||
&& (providerModelPolicy.providerType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
|
|
||||||
const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected;
|
|
||||||
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
|
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
|
||||||
const selectedModelVersion = selectedModel?.id?.includes(':')
|
const selectedModelVersion = selectedModel?.id?.includes(':')
|
||||||
? selectedModel.id.slice(selectedModel.id.indexOf(':'))
|
? selectedModel.id.slice(selectedModel.id.indexOf(':'))
|
||||||
|
|
@ -642,53 +604,11 @@ export function SettingsModal({
|
||||||
setLocalProviderType(defaults.providerType);
|
setLocalProviderType(defaults.providerType);
|
||||||
setModelValue(defaults.defaultModel);
|
setModelValue(defaults.defaultModel);
|
||||||
setLocalTTSInstructions(defaults.defaultInstructions);
|
setLocalTTSInstructions(defaults.defaultInstructions);
|
||||||
if (provider.shared) {
|
|
||||||
// Shared admin provider — credentials live on the server.
|
|
||||||
setLocalApiKey('');
|
|
||||||
setLocalBaseUrl('');
|
|
||||||
} else if (isBuiltInTtsProviderId(provider.providerType)) {
|
|
||||||
setLocalBaseUrl(defaultBaseUrlForProviderType(provider.providerType));
|
|
||||||
}
|
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{restrictUserApiKeys && (
|
|
||||||
<p className="text-xs text-soft">
|
|
||||||
This instance restricts user API keys. TTS runs through admin-configured shared providers only.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{shouldShowBaseUrl && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className={fieldLabelClass}>
|
|
||||||
API Base URL
|
|
||||||
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={localBaseUrl}
|
|
||||||
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
|
||||||
placeholder="Using environment variable"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{shouldShowApiKey && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className={fieldLabelClass}>
|
|
||||||
API Key
|
|
||||||
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={localApiKey}
|
|
||||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
|
||||||
placeholder="Using environment variable"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isSharedSelected && (
|
{isSharedSelected && (
|
||||||
<p className="text-xs text-soft">
|
<p className="text-xs text-soft">
|
||||||
This is a shared provider configured by an admin. API key and base URL are managed server-side.
|
This is a shared provider configured by an admin. API key and base URL are managed server-side.
|
||||||
|
|
@ -773,8 +693,6 @@ export function SettingsModal({
|
||||||
providerRef: runtimeConfig.defaultTtsProvider,
|
providerRef: runtimeConfig.defaultTtsProvider,
|
||||||
sharedProviders,
|
sharedProviders,
|
||||||
});
|
});
|
||||||
setLocalApiKey('');
|
|
||||||
setLocalBaseUrl('');
|
|
||||||
setLocalProviderRef(defaults.providerRef);
|
setLocalProviderRef(defaults.providerRef);
|
||||||
setLocalProviderType(defaults.providerType);
|
setLocalProviderType(defaults.providerType);
|
||||||
setModelValue(defaults.defaultModel);
|
setModelValue(defaults.defaultModel);
|
||||||
|
|
@ -796,10 +714,6 @@ export function SettingsModal({
|
||||||
providerType: selectedProviderType,
|
providerType: selectedProviderType,
|
||||||
sharedProviders,
|
sharedProviders,
|
||||||
});
|
});
|
||||||
await updateConfig({
|
|
||||||
apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
|
|
||||||
baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
|
|
||||||
});
|
|
||||||
await updateConfigKey('providerRef', selectedProviderRef);
|
await updateConfigKey('providerRef', selectedProviderRef);
|
||||||
await updateConfigKey('providerType', selectedProviderType);
|
await updateConfigKey('providerType', selectedProviderType);
|
||||||
const finalModel = showAllProviderModels
|
const finalModel = showAllProviderModels
|
||||||
|
|
@ -807,16 +721,6 @@ export function SettingsModal({
|
||||||
: defaults.defaultModel;
|
: defaults.defaultModel;
|
||||||
await updateConfigKey('ttsModel', finalModel);
|
await updateConfigKey('ttsModel', finalModel);
|
||||||
await updateConfigKey('ttsInstructions', localTTSInstructions);
|
await updateConfigKey('ttsInstructions', localTTSInstructions);
|
||||||
// Push the change to the server immediately rather than waiting on
|
|
||||||
// the debounce, so a quick reload can't lose the save. Surface
|
|
||||||
// failures instead of silently dropping them.
|
|
||||||
try {
|
|
||||||
await flushUserPreferencesSync();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save TTS provider settings:', error);
|
|
||||||
toast.error('Failed to save provider settings. Please try again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ export type ClaimableCounts = {
|
||||||
preferences: number;
|
preferences: number;
|
||||||
progress: number;
|
progress: number;
|
||||||
documentSettings: number;
|
documentSettings: number;
|
||||||
|
folders: number;
|
||||||
|
onboarding: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toClaimableCounts(value: unknown): ClaimableCounts {
|
function toClaimableCounts(value: unknown): ClaimableCounts {
|
||||||
|
|
@ -21,6 +23,8 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
|
||||||
preferences: Number(rec.preferences ?? 0),
|
preferences: Number(rec.preferences ?? 0),
|
||||||
progress: Number(rec.progress ?? 0),
|
progress: Number(rec.progress ?? 0),
|
||||||
documentSettings: Number(rec.documentSettings ?? 0),
|
documentSettings: Number(rec.documentSettings ?? 0),
|
||||||
|
folders: Number(rec.folders ?? 0),
|
||||||
|
onboarding: Number(rec.onboarding ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +90,8 @@ export default function ClaimDataModal({
|
||||||
<li>{claimableCounts.preferences} preference set(s)</li>
|
<li>{claimableCounts.preferences} preference set(s)</li>
|
||||||
<li>{claimableCounts.progress} reading progress record(s)</li>
|
<li>{claimableCounts.progress} reading progress record(s)</li>
|
||||||
<li>{claimableCounts.documentSettings} document setting(s)</li>
|
<li>{claimableCounts.documentSettings} document setting(s)</li>
|
||||||
|
<li>{claimableCounts.folders} folder(s)</li>
|
||||||
|
<li>{claimableCounts.onboarding} onboarding state record(s)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import type {
|
import type {
|
||||||
DocumentListDocument,
|
DocumentListDocument,
|
||||||
|
|
@ -13,11 +12,9 @@ import type {
|
||||||
SortDirection,
|
SortDirection,
|
||||||
ViewMode,
|
ViewMode,
|
||||||
} from '@/types/documents';
|
} from '@/types/documents';
|
||||||
import {
|
import { useFolders } from '@/hooks/useFolders';
|
||||||
getDocumentListState,
|
import { useUserPreferences } from '@/hooks/useUserPreferences';
|
||||||
getDocumentRecentlyOpenedMap,
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
saveDocumentListState,
|
|
||||||
} from '@/lib/client/dexie';
|
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
|
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
|
||||||
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
|
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
|
||||||
|
|
@ -202,7 +199,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
normalizeViewMode(cachedState?.viewMode ?? DEFAULT_STATE.viewMode),
|
normalizeViewMode(cachedState?.viewMode ?? DEFAULT_STATE.viewMode),
|
||||||
);
|
);
|
||||||
const [iconSize, setIconSize] = useState<IconSize>(cachedState?.iconSize ?? DEFAULT_STATE.iconSize);
|
const [iconSize, setIconSize] = useState<IconSize>(cachedState?.iconSize ?? DEFAULT_STATE.iconSize);
|
||||||
const [folders, setFolders] = useState<Folder[]>(cachedState?.folders ?? DEFAULT_STATE.folders);
|
const [folders, setFolders] = useState<Folder[]>([]);
|
||||||
const [showHint, setShowHint] = useState(cachedState?.showHint ?? true);
|
const [showHint, setShowHint] = useState(cachedState?.showHint ?? true);
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(cachedState?.sidebarWidth ?? DEFAULT_STATE.sidebarWidth);
|
const [sidebarWidth, setSidebarWidth] = useState(cachedState?.sidebarWidth ?? DEFAULT_STATE.sidebarWidth);
|
||||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>(cachedState?.sidebarFilter ?? 'all');
|
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>(cachedState?.sidebarFilter ?? 'all');
|
||||||
|
|
@ -213,6 +210,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
||||||
|
|
||||||
const [isInitialized, setIsInitialized] = useState(cachedState !== null);
|
const [isInitialized, setIsInitialized] = useState(cachedState !== null);
|
||||||
|
const preferenceWriteTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
|
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
|
||||||
const [pendingMerge, setPendingMerge] = useState<
|
const [pendingMerge, setPendingMerge] = useState<
|
||||||
|
|
@ -235,18 +233,20 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
isHTMLLoading,
|
isHTMLLoading,
|
||||||
} = useDocuments();
|
} = useDocuments();
|
||||||
|
const { data: session, isPending: isSessionPending } = useAuthSession();
|
||||||
|
const sessionId = session?.user?.id ?? 'no-session';
|
||||||
|
const { query: preferencesQuery, mutation: preferencesMutation } = useUserPreferences(sessionId, !isSessionPending);
|
||||||
|
const persistPreferences = preferencesMutation.mutate;
|
||||||
|
const folderState = useFolders();
|
||||||
|
|
||||||
// Load saved state.
|
// Load saved state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
if (preferencesQuery.isPending) return;
|
||||||
(async () => {
|
const saved = preferencesQuery.data?.preferences.documentListState;
|
||||||
const saved = await getDocumentListState();
|
|
||||||
if (cancelled) return;
|
|
||||||
if (saved) {
|
if (saved) {
|
||||||
cachedDocumentListState = saved;
|
cachedDocumentListState = saved;
|
||||||
setSortBy(saved.sortBy);
|
setSortBy(saved.sortBy);
|
||||||
setSortDirection(saved.sortDirection);
|
setSortDirection(saved.sortDirection);
|
||||||
setFolders(saved.folders ?? []);
|
|
||||||
setShowHint(saved.showHint ?? true);
|
setShowHint(saved.showHint ?? true);
|
||||||
setViewMode(normalizeViewMode(saved.viewMode));
|
setViewMode(normalizeViewMode(saved.viewMode));
|
||||||
setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize);
|
setIconSize(saved.iconSize ?? DEFAULT_STATE.iconSize);
|
||||||
|
|
@ -257,7 +257,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
cachedDocumentListState = null;
|
cachedDocumentListState = null;
|
||||||
setSortBy(DEFAULT_STATE.sortBy);
|
setSortBy(DEFAULT_STATE.sortBy);
|
||||||
setSortDirection(DEFAULT_STATE.sortDirection);
|
setSortDirection(DEFAULT_STATE.sortDirection);
|
||||||
setFolders(DEFAULT_STATE.folders);
|
|
||||||
setShowHint(DEFAULT_STATE.showHint);
|
setShowHint(DEFAULT_STATE.showHint);
|
||||||
setViewMode(DEFAULT_STATE.viewMode);
|
setViewMode(DEFAULT_STATE.viewMode);
|
||||||
setIconSize(DEFAULT_STATE.iconSize);
|
setIconSize(DEFAULT_STATE.iconSize);
|
||||||
|
|
@ -266,11 +265,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
setSidebarOpen(!DEFAULT_STATE.sidebarCollapsed);
|
setSidebarOpen(!DEFAULT_STATE.sidebarCollapsed);
|
||||||
}
|
}
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
})();
|
}, [preferencesQuery.data?.preferences.documentListState, preferencesQuery.isPending]);
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Persist.
|
// Persist.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -278,7 +273,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
const state: DocumentListState = {
|
const state: DocumentListState = {
|
||||||
sortBy,
|
sortBy,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
folders,
|
folders: [],
|
||||||
collapsedFolders: [],
|
collapsedFolders: [],
|
||||||
showHint,
|
showHint,
|
||||||
viewMode,
|
viewMode,
|
||||||
|
|
@ -288,11 +283,34 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
sidebarCollapsed: !sidebarOpen,
|
sidebarCollapsed: !sidebarOpen,
|
||||||
};
|
};
|
||||||
cachedDocumentListState = state;
|
cachedDocumentListState = state;
|
||||||
void saveDocumentListState(state);
|
const saved = preferencesQuery.data?.preferences.documentListState;
|
||||||
|
if (
|
||||||
|
saved
|
||||||
|
&& saved.sortBy === state.sortBy
|
||||||
|
&& saved.sortDirection === state.sortDirection
|
||||||
|
&& (saved.showHint ?? true) === state.showHint
|
||||||
|
&& normalizeViewMode(saved.viewMode) === state.viewMode
|
||||||
|
&& (saved.iconSize ?? DEFAULT_STATE.iconSize) === state.iconSize
|
||||||
|
&& (saved.sidebarWidth ?? DEFAULT_STATE.sidebarWidth) === state.sidebarWidth
|
||||||
|
&& (saved.sidebarFilter ?? 'all') === state.sidebarFilter
|
||||||
|
&& (saved.sidebarCollapsed ?? false) === state.sidebarCollapsed
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (preferenceWriteTimer.current) clearTimeout(preferenceWriteTimer.current);
|
||||||
|
preferenceWriteTimer.current = setTimeout(() => {
|
||||||
|
persistPreferences({ documentListState: state });
|
||||||
|
preferenceWriteTimer.current = null;
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
if (preferenceWriteTimer.current) {
|
||||||
|
clearTimeout(preferenceWriteTimer.current);
|
||||||
|
preferenceWriteTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
sortBy,
|
sortBy,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
folders,
|
|
||||||
showHint,
|
showHint,
|
||||||
viewMode,
|
viewMode,
|
||||||
iconSize,
|
iconSize,
|
||||||
|
|
@ -300,6 +318,8 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
sidebarFilter,
|
sidebarFilter,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
|
persistPreferences,
|
||||||
|
preferencesQuery.data?.preferences.documentListState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Mobile drawer should never auto-open from persisted desktop state.
|
// Mobile drawer should never auto-open from persisted desktop state.
|
||||||
|
|
@ -321,28 +341,26 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
() => rawDocuments.map((d) => documentIdentityKey(d)).sort().join('|'),
|
() => rawDocuments.map((d) => documentIdentityKey(d)).sort().join('|'),
|
||||||
[rawDocuments],
|
[rawDocuments],
|
||||||
);
|
);
|
||||||
const recentlyOpenedById = useLiveQuery<Record<string, number>, Record<string, number>>(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
return await getDocumentRecentlyOpenedMap();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to load recently opened cache metadata:', err);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[rawDocumentIdsKey],
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
const allDocuments: DocumentListDocument[] = useMemo(
|
const allDocuments: DocumentListDocument[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rawDocuments.map((doc) => ({
|
rawDocuments.map((doc) => ({
|
||||||
...doc,
|
...doc,
|
||||||
recentlyOpenedAt: recentlyOpenedById[documentIdentityKey(doc)] ?? 0,
|
recentlyOpenedAt: doc.recentlyOpenedAt ?? 0,
|
||||||
})),
|
})),
|
||||||
[rawDocuments, recentlyOpenedById],
|
[rawDocuments],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const serverFolders = folderState.query.data ?? [];
|
||||||
|
setFolders(serverFolders.map((folder) => ({
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
documents: rawDocuments
|
||||||
|
.filter((doc) => doc.folderId === folder.id)
|
||||||
|
.map((doc) => ({ ...doc, folderId: folder.id })),
|
||||||
|
})));
|
||||||
|
}, [folderState.query.data, rawDocumentIdsKey, rawDocuments]);
|
||||||
|
|
||||||
const allDocumentsById = useMemo(() => {
|
const allDocumentsById = useMemo(() => {
|
||||||
const map = new Map<string, DocumentListDocument>();
|
const map = new Map<string, DocumentListDocument>();
|
||||||
for (const doc of allDocuments) map.set(documentIdentityKey(doc), doc);
|
for (const doc of allDocuments) map.set(documentIdentityKey(doc), doc);
|
||||||
|
|
@ -473,10 +491,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
return { ...f, documents: [...f.documents, ...newDocs] };
|
return { ...f, documents: [...f.documents, ...newDocs] };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
folderState.move.mutate({ documentIds: item.docs.map((doc) => doc.id), folderId });
|
||||||
setSidebarFilter(`folder:${folderId}`);
|
setSidebarFilter(`folder:${folderId}`);
|
||||||
selection.clear();
|
selection.clear();
|
||||||
},
|
},
|
||||||
[selection],
|
[folderState.move, selection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMergeIntoFolder = useCallback(
|
const handleMergeIntoFolder = useCallback(
|
||||||
|
|
@ -491,50 +510,74 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createFolderFromPending = useCallback(() => {
|
const createFolderFromPending = useCallback(async () => {
|
||||||
if (!pendingMerge) return;
|
if (!pendingMerge) return;
|
||||||
const name =
|
const name =
|
||||||
newFolderName.trim() ||
|
newFolderName.trim() ||
|
||||||
generateDefaultFolderName(pendingMerge.sources[0], pendingMerge.target);
|
generateDefaultFolderName(pendingMerge.sources[0], pendingMerge.target);
|
||||||
const folderId = `folder-${Date.now()}`;
|
const documentIds = [...pendingMerge.sources, pendingMerge.target].map((doc) => doc.id);
|
||||||
setFolders((prev) => [
|
const folderId = crypto.randomUUID();
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: folderId,
|
|
||||||
name,
|
|
||||||
documents: [
|
|
||||||
...pendingMerge.sources.map((d) => ({ ...d, folderId })),
|
|
||||||
{ ...pendingMerge.target, folderId },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setPendingMerge(null);
|
setPendingMerge(null);
|
||||||
setNewFolderName('');
|
setNewFolderName('');
|
||||||
setShowHint(false);
|
setShowHint(false);
|
||||||
setSidebarFilter(`folder:${folderId}`);
|
setSidebarFilter(`folder:${folderId}`);
|
||||||
selection.clear();
|
selection.clear();
|
||||||
}, [pendingMerge, newFolderName, selection]);
|
const { folder } = await folderState.create.mutateAsync({
|
||||||
|
id: folderId,
|
||||||
|
name,
|
||||||
|
documentIds,
|
||||||
|
});
|
||||||
|
setSidebarFilter(`folder:${folder.id}`);
|
||||||
|
}, [folderState.create, pendingMerge, newFolderName, selection]);
|
||||||
|
|
||||||
|
const handleDismissHint = useCallback(() => {
|
||||||
|
setShowHint(false);
|
||||||
|
persistPreferences({
|
||||||
|
documentListState: {
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
folders: [],
|
||||||
|
collapsedFolders: [],
|
||||||
|
showHint: false,
|
||||||
|
viewMode,
|
||||||
|
iconSize,
|
||||||
|
sidebarWidth,
|
||||||
|
sidebarFilter,
|
||||||
|
sidebarCollapsed: !sidebarOpen,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
iconSize,
|
||||||
|
persistPreferences,
|
||||||
|
sidebarFilter,
|
||||||
|
sidebarOpen,
|
||||||
|
sidebarWidth,
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
viewMode,
|
||||||
|
]);
|
||||||
|
|
||||||
const createManualFolder = useCallback(() => {
|
const createManualFolder = useCallback(() => {
|
||||||
const name = newFolderName.trim() || `New Folder`;
|
const name = newFolderName.trim() || `New Folder`;
|
||||||
const folderId = `folder-${Date.now()}`;
|
folderState.create.mutate({ name });
|
||||||
setFolders((prev) => [...prev, { id: folderId, name, documents: [] }]);
|
|
||||||
setNewFolderName('');
|
setNewFolderName('');
|
||||||
setManualFolderPrompt(false);
|
setManualFolderPrompt(false);
|
||||||
setSidebarFilter(`folder:${folderId}`);
|
setSidebarFilter('all');
|
||||||
}, [newFolderName]);
|
}, [folderState.create, newFolderName]);
|
||||||
|
|
||||||
const handleDeleteFolder = useCallback((folderId: string) => {
|
const handleDeleteFolder = useCallback((folderId: string) => {
|
||||||
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
||||||
|
folderState.remove.mutate(folderId);
|
||||||
if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all');
|
if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all');
|
||||||
}, [sidebarFilter]);
|
}, [folderState.remove, sidebarFilter]);
|
||||||
|
|
||||||
const handleClearFolders = useCallback(() => {
|
const handleClearFolders = useCallback(() => {
|
||||||
setFolders([]);
|
setFolders([]);
|
||||||
|
folderState.clear.mutate();
|
||||||
if (sidebarFilter.startsWith('folder:')) setSidebarFilter('all');
|
if (sidebarFilter.startsWith('folder:')) setSidebarFilter('all');
|
||||||
setClearFoldersPrompt(false);
|
setClearFoldersPrompt(false);
|
||||||
selection.clear();
|
selection.clear();
|
||||||
}, [selection, sidebarFilter]);
|
}, [folderState.clear, selection, sidebarFilter]);
|
||||||
|
|
||||||
// Status bar summary.
|
// Status bar summary.
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
|
|
@ -666,7 +709,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
Drag files onto each other to make folders. Drop into the sidebar to move.
|
Drag files onto each other to make folders. Drop into the sidebar to move.
|
||||||
</p>
|
</p>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setShowHint(false)}
|
onClick={handleDismissHint}
|
||||||
size="xs"
|
size="xs"
|
||||||
className="h-6 w-6"
|
className="h-6 w-6"
|
||||||
aria-label="Dismiss hint"
|
aria-label="Dismiss hint"
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
|
||||||
if (status.kind === 'ready') {
|
if (status.kind === 'ready') {
|
||||||
const primedUrl = await primeDocumentPreviewCache(
|
const primedUrl = await primeDocumentPreviewCache(
|
||||||
doc.id,
|
doc.id,
|
||||||
Number(doc.lastModified),
|
status.previewVersion || Number(doc.lastModified),
|
||||||
previewKey,
|
previewKey,
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
).catch(() => null);
|
).catch(() => null);
|
||||||
|
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
|
|
||||||
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
|
||||||
import type { BaseDocument } from '@/types/documents';
|
|
||||||
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
|
|
||||||
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
|
|
||||||
|
|
||||||
type DexieMigrationModalProps = {
|
|
||||||
isOpen: boolean;
|
|
||||||
localCount: number;
|
|
||||||
missingCount: number;
|
|
||||||
onComplete: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DexieMigrationModal({
|
|
||||||
isOpen,
|
|
||||||
localCount,
|
|
||||||
missingCount,
|
|
||||||
onComplete,
|
|
||||||
}: DexieMigrationModalProps) {
|
|
||||||
const { refreshDocuments } = useDocuments();
|
|
||||||
const [displayMissingCount, setDisplayMissingCount] = useState(missingCount);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [status, setStatus] = useState<string>('');
|
|
||||||
|
|
||||||
const closeDisabled = isUploading;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDisplayMissingCount(missingCount);
|
|
||||||
}, [missingCount, isOpen]);
|
|
||||||
|
|
||||||
const loadLocalDexieDocs = useCallback(async (): Promise<{
|
|
||||||
docs: BaseDocument[];
|
|
||||||
pdfById: Map<string, { id: string; name: string; size: number; lastModified: number; data: ArrayBuffer }>;
|
|
||||||
epubById: Map<string, { id: string; name: string; size: number; lastModified: number; data: ArrayBuffer }>;
|
|
||||||
htmlById: Map<string, { id: string; name: string; size: number; lastModified: number; data: string }>;
|
|
||||||
}> => {
|
|
||||||
const [pdfs, epubs, htmls] = await Promise.all([getAllPdfDocuments(), getAllEpubDocuments(), getAllHtmlDocuments()]);
|
|
||||||
const docs: BaseDocument[] = [
|
|
||||||
...pdfs.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'pdf' as const })),
|
|
||||||
...epubs.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'epub' as const })),
|
|
||||||
...htmls.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'html' as const })),
|
|
||||||
];
|
|
||||||
const pdfById = new Map(pdfs.map((d) => [d.id, d] as const));
|
|
||||||
const epubById = new Map(epubs.map((d) => [d.id, d] as const));
|
|
||||||
const htmlById = new Map(htmls.map((d) => [d.id, d] as const));
|
|
||||||
return { docs, pdfById, epubById, htmlById };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const title = 'Upload your local documents?';
|
|
||||||
|
|
||||||
const handleSkip = useCallback(async () => {
|
|
||||||
await updateAppConfig({ documentsMigrationPrompted: true });
|
|
||||||
onComplete();
|
|
||||||
}, [onComplete]);
|
|
||||||
|
|
||||||
const handleUpload = useCallback(async () => {
|
|
||||||
setIsUploading(true);
|
|
||||||
setProgress(0);
|
|
||||||
setStatus('Preparing upload...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { docs, pdfById, epubById, htmlById } = await loadLocalDexieDocs();
|
|
||||||
|
|
||||||
const serverDocs = await listDocuments().catch(() => null);
|
|
||||||
const serverIds = serverDocs ? new Set(serverDocs.map((d) => d.id)) : null;
|
|
||||||
const toUpload = serverIds ? docs.filter((d) => !serverIds.has(d.id)) : docs;
|
|
||||||
setDisplayMissingCount(toUpload.length);
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
for (let i = 0; i < toUpload.length; i++) {
|
|
||||||
const doc = toUpload[i];
|
|
||||||
setStatus(`Uploading ${i + 1}/${toUpload.length}: ${doc.name}`);
|
|
||||||
setProgress((i / Math.max(1, toUpload.length)) * 100);
|
|
||||||
|
|
||||||
// Pull raw data from Dexie for this doc
|
|
||||||
if (doc.type === 'pdf') {
|
|
||||||
const full = pdfById.get(doc.id) ?? null;
|
|
||||||
if (!full) continue;
|
|
||||||
const bytes = full.data.slice(0);
|
|
||||||
const file = new File([full.data], full.name, {
|
|
||||||
type: mimeTypeForDoc(doc),
|
|
||||||
lastModified: full.lastModified,
|
|
||||||
});
|
|
||||||
const uploaded = await uploadDocuments([file]);
|
|
||||||
const stored = uploaded[0] ?? null;
|
|
||||||
if (stored) {
|
|
||||||
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
|
|
||||||
}
|
|
||||||
} else if (doc.type === 'epub') {
|
|
||||||
const full = epubById.get(doc.id) ?? null;
|
|
||||||
if (!full) continue;
|
|
||||||
const bytes = full.data.slice(0);
|
|
||||||
const file = new File([full.data], full.name, {
|
|
||||||
type: mimeTypeForDoc(doc),
|
|
||||||
lastModified: full.lastModified,
|
|
||||||
});
|
|
||||||
const uploaded = await uploadDocuments([file]);
|
|
||||||
const stored = uploaded[0] ?? null;
|
|
||||||
if (stored) {
|
|
||||||
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const full = htmlById.get(doc.id) ?? null;
|
|
||||||
if (!full) continue;
|
|
||||||
const bytes = encoder.encode(full.data).buffer;
|
|
||||||
const file = new File([full.data], full.name, {
|
|
||||||
type: mimeTypeForDoc(doc),
|
|
||||||
lastModified: full.lastModified,
|
|
||||||
});
|
|
||||||
const uploaded = await uploadDocuments([file]);
|
|
||||||
const stored = uploaded[0] ?? null;
|
|
||||||
if (stored) {
|
|
||||||
await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(100);
|
|
||||||
setStatus('Refreshing...');
|
|
||||||
await refreshDocuments();
|
|
||||||
await updateAppConfig({ documentsMigrationPrompted: true });
|
|
||||||
onComplete();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Dexie migration upload failed:', err);
|
|
||||||
setStatus('Upload failed. You can retry or skip.');
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
}, [loadLocalDexieDocs, onComplete, refreshDocuments]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalFrame
|
|
||||||
open={isOpen}
|
|
||||||
onClose={() => {
|
|
||||||
if (!closeDisabled) onComplete();
|
|
||||||
}}
|
|
||||||
panelTestId="migration-modal"
|
|
||||||
className="z-[80]"
|
|
||||||
>
|
|
||||||
<ModalTitle className="mb-4">{title}</ModalTitle>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-soft mb-2">
|
|
||||||
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
|
|
||||||
{displayMissingCount > 0 ? (
|
|
||||||
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
|
|
||||||
) : null}
|
|
||||||
{' '}This app now stores documents on the server and keeps a local cache for speed.
|
|
||||||
</p>
|
|
||||||
{isUploading && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-soft">{status}</p>
|
|
||||||
<div className="h-2 w-full rounded bg-surface-sunken">
|
|
||||||
<div className="progress-fill h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isUploading && status ? <p className="text-xs text-danger">{status}</p> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
|
||||||
<Button
|
|
||||||
data-testid="migration-skip-button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSkip}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
{isUploading ? 'Uploading…' : 'Upload'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ModalFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +1,18 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
|
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues } from '@/types/config';
|
||||||
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie';
|
|
||||||
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
|
|
||||||
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
|
||||||
import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
|
import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
|
||||||
import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
|
|
||||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
||||||
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
|
|
||||||
import { applyConfigUpdate } from '@/lib/client/config/updates';
|
import { applyConfigUpdate } from '@/lib/client/config/updates';
|
||||||
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
||||||
import toast from 'react-hot-toast';
|
import { useUserPreferences } from '@/hooks/useUserPreferences';
|
||||||
|
import type { SyncedPreferencesPatch } from '@/types/user-state';
|
||||||
|
|
||||||
export type { ViewType } from '@/types/config';
|
export type { ViewType } from '@/types/config';
|
||||||
|
|
||||||
/** Configuration values for the application */
|
|
||||||
|
|
||||||
/** Interface defining the configuration context shape and functionality */
|
|
||||||
interface ConfigContextType {
|
interface ConfigContextType {
|
||||||
apiKey: string;
|
|
||||||
baseUrl: string;
|
|
||||||
viewType: ViewType;
|
viewType: ViewType;
|
||||||
voiceSpeed: number;
|
voiceSpeed: number;
|
||||||
audioPlayerSpeed: number;
|
audioPlayerSpeed: number;
|
||||||
|
|
@ -40,7 +31,6 @@ interface ConfigContextType {
|
||||||
ttsModel: string;
|
ttsModel: string;
|
||||||
ttsInstructions: string;
|
ttsInstructions: string;
|
||||||
savedVoices: SavedVoices;
|
savedVoices: SavedVoices;
|
||||||
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
|
|
||||||
updateConfigKey: <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => Promise<void>;
|
updateConfigKey: <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => Promise<void>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isDBReady: boolean;
|
isDBReady: boolean;
|
||||||
|
|
@ -54,456 +44,82 @@ interface ConfigContextType {
|
||||||
|
|
||||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider component for application configuration
|
|
||||||
* Manages global configuration state and persistence
|
|
||||||
* @param {Object} props - Component props
|
|
||||||
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
|
||||||
*/
|
|
||||||
export function ConfigProvider({ children }: { children: ReactNode }) {
|
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const { data: session, isPending: isSessionPending } = useAuthSession();
|
||||||
const [isDBReady, setIsDBReady] = useState(false);
|
const sessionId = session?.user?.id ?? 'no-session';
|
||||||
const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
|
|
||||||
const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
|
|
||||||
const showAllProviderModels = useFeatureFlag('showAllProviderModels');
|
|
||||||
const didRunStartupMigrations = useRef(false);
|
|
||||||
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
|
|
||||||
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
|
|
||||||
const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders();
|
const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders();
|
||||||
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
|
||||||
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
|
||||||
// The instance/admin default provider. An empty user providerRef "inherits"
|
|
||||||
// this, resolved (admin slug -> concrete provider) where the value is used.
|
|
||||||
const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider;
|
const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider;
|
||||||
|
const { query, mutation } = useUserPreferences(sessionId, !isSessionPending);
|
||||||
|
|
||||||
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
const config = useMemo<AppConfigValues>(() => ({
|
||||||
if (sessionKey === 'no-session') return;
|
...APP_CONFIG_DEFAULTS,
|
||||||
|
...(query.data?.preferences ?? {}),
|
||||||
|
}), [query.data?.preferences]);
|
||||||
|
|
||||||
const syncedPatch: SyncedPreferencesPatch = {};
|
const effectiveProvider = useMemo(() => resolveProviderDefaults({
|
||||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
providerRef: config.providerRef,
|
||||||
if (!(key in patch)) continue;
|
providerType: config.providerType,
|
||||||
const value = patch[key];
|
|
||||||
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,
|
sharedProviders,
|
||||||
fallbackProviderRef: adminDefaultProviderRef,
|
fallbackProviderRef: adminDefaultProviderRef,
|
||||||
});
|
}), [adminDefaultProviderRef, config.providerRef, config.providerType, sharedProviders]);
|
||||||
if (!providerDefaults.defaultModel) return;
|
|
||||||
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
|
|
||||||
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
|
|
||||||
updateAppConfig(patch).catch((error) => {
|
|
||||||
console.warn('Failed to enforce provider default model restriction:', error);
|
|
||||||
});
|
|
||||||
queueSyncedPreferencePatch(patch);
|
|
||||||
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, adminDefaultProviderRef, queueSyncedPreferencePatch, sharedProvidersLoading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDBReady || !appConfig || isSessionPending) return;
|
|
||||||
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
|
|
||||||
didAttemptInitialPreferenceSeedForSession.current = sessionKey;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
try {
|
|
||||||
const remote = await getUserPreferences({ signal: controller.signal });
|
|
||||||
if (remote?.hasStoredPreferences) return;
|
|
||||||
|
|
||||||
// Seed only user-customized (non-default) values. This prevents fresh/default
|
|
||||||
// profiles from overwriting existing server values during first-run races.
|
|
||||||
const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true });
|
|
||||||
if (Object.keys(patch).length === 0) return;
|
|
||||||
|
|
||||||
await putUserPreferences(patch, { clientUpdatedAtMs: Date.now(), signal: controller.signal });
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as Error)?.name === 'AbortError') return;
|
|
||||||
console.warn('Failed to seed initial synced preferences from local Dexie:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
run().catch((error) => {
|
|
||||||
if ((error as Error)?.name === 'AbortError') return;
|
|
||||||
console.warn('Initial synced preferences seed failed:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [isDBReady, appConfig, isSessionPending, sessionKey]);
|
|
||||||
|
|
||||||
// Destructure for convenience and to match context shape
|
|
||||||
const {
|
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
viewType,
|
|
||||||
voiceSpeed,
|
|
||||||
audioPlayerSpeed,
|
|
||||||
voice,
|
|
||||||
skipBlank,
|
|
||||||
epubTheme,
|
|
||||||
headerMargin,
|
|
||||||
footerMargin,
|
|
||||||
leftMargin,
|
|
||||||
rightMargin,
|
|
||||||
providerRef,
|
|
||||||
providerType: _persistedProviderType,
|
|
||||||
ttsModel,
|
|
||||||
ttsInstructions,
|
|
||||||
savedVoices,
|
|
||||||
segmentPreloadDepthPages,
|
|
||||||
segmentPreloadSentenceLookahead,
|
|
||||||
ttsSegmentMaxBlockLength,
|
|
||||||
pdfHighlightEnabled,
|
|
||||||
pdfWordHighlightEnabled,
|
|
||||||
epubHighlightEnabled,
|
|
||||||
epubWordHighlightEnabled,
|
|
||||||
htmlHighlightEnabled,
|
|
||||||
htmlWordHighlightEnabled,
|
|
||||||
} = config || APP_CONFIG_DEFAULTS;
|
|
||||||
// Resolve the effective provider for consumers. An empty stored providerRef
|
|
||||||
// means "inherit the admin default", which we resolve here so the reader,
|
|
||||||
// voice pickers, and settings UI all see a concrete, usable provider without
|
|
||||||
// mutating the stored ("inherit") value.
|
|
||||||
const effectiveProvider = useMemo(
|
|
||||||
() => resolveProviderDefaults({
|
|
||||||
providerRef,
|
|
||||||
providerType: _persistedProviderType,
|
|
||||||
sharedProviders,
|
|
||||||
fallbackProviderRef: adminDefaultProviderRef,
|
|
||||||
}),
|
|
||||||
[providerRef, _persistedProviderType, sharedProviders, adminDefaultProviderRef],
|
|
||||||
);
|
|
||||||
const effectiveProviderRef = effectiveProvider.providerRef;
|
const effectiveProviderRef = effectiveProvider.providerRef;
|
||||||
const providerType = effectiveProvider.providerType;
|
const effectiveTtsModel = config.ttsModel || effectiveProvider.defaultModel;
|
||||||
const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel;
|
const effectiveTtsInstructions = config.ttsInstructions || effectiveProvider.defaultInstructions;
|
||||||
const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions;
|
|
||||||
|
|
||||||
useEffect(() => {
|
const updatePatch = async (patch: Partial<AppConfigValues>) => {
|
||||||
if (!isDBReady || !appConfig || sharedProvidersLoading) return;
|
await mutation.mutateAsync(patch as SyncedPreferencesPatch);
|
||||||
// Only persist a resolved providerType for an explicitly chosen provider.
|
|
||||||
// While inheriting (empty providerRef) the type stays unset in storage.
|
|
||||||
if (!appConfig.providerRef) return;
|
|
||||||
if (appConfig.providerType === providerType) return;
|
|
||||||
const patch: Partial<AppConfigRow> = { providerType };
|
|
||||||
updateAppConfig(patch).catch((error) => {
|
|
||||||
console.warn('Failed to persist resolved providerType:', error);
|
|
||||||
});
|
|
||||||
queueSyncedPreferencePatch(patch);
|
|
||||||
}, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]);
|
|
||||||
void _persistedProviderType;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDBReady || !appConfig || sharedProvidersLoading) return;
|
|
||||||
// Inheriting (empty providerRef): the effective model is resolved at read
|
|
||||||
// time; don't write a concrete model into the "inherit" state.
|
|
||||||
if (!appConfig.providerRef) return;
|
|
||||||
const providerDefaults = resolveProviderDefaults({
|
|
||||||
providerRef: appConfig.providerRef,
|
|
||||||
providerType: appConfig.providerType,
|
|
||||||
sharedProviders,
|
|
||||||
fallbackProviderRef: adminDefaultProviderRef,
|
|
||||||
});
|
|
||||||
if (!providerDefaults.defaultModel) return;
|
|
||||||
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
|
|
||||||
// Heal stale fallback model values that were written while the provider UI
|
|
||||||
// was disabled and shared provider context was unavailable.
|
|
||||||
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) return;
|
|
||||||
|
|
||||||
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
|
|
||||||
updateAppConfig(patch).catch((error) => {
|
|
||||||
console.warn('Failed to normalize shared-provider default model:', error);
|
|
||||||
});
|
|
||||||
queueSyncedPreferencePatch(patch);
|
|
||||||
}, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates multiple configuration values simultaneously
|
|
||||||
* Only saves API credentials if they are explicitly set
|
|
||||||
*/
|
|
||||||
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const updates: Partial<AppConfigRow> = {};
|
|
||||||
if (newConfig.apiKey !== undefined) {
|
|
||||||
updates.apiKey = newConfig.apiKey;
|
|
||||||
}
|
|
||||||
if (newConfig.baseUrl !== undefined) {
|
|
||||||
updates.baseUrl = newConfig.baseUrl;
|
|
||||||
}
|
|
||||||
if (newConfig.viewType !== undefined) {
|
|
||||||
updates.viewType = newConfig.viewType;
|
|
||||||
}
|
|
||||||
await updateAppConfig(updates);
|
|
||||||
queueSyncedPreferencePatch(updates);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating config:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a single configuration value by key
|
|
||||||
* @param {K} key - The configuration key to update
|
|
||||||
* @param {AppConfigValues[K]} value - The new value for the configuration
|
|
||||||
*/
|
|
||||||
const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
|
const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
|
||||||
try {
|
const { syncPatch } = applyConfigUpdate({
|
||||||
setIsLoading(true);
|
|
||||||
const { storagePatch, syncPatch } = applyConfigUpdate({
|
|
||||||
providerRef: effectiveProviderRef,
|
providerRef: effectiveProviderRef,
|
||||||
providerType,
|
providerType: effectiveProvider.providerType,
|
||||||
ttsModel: effectiveTtsModel,
|
ttsModel: effectiveTtsModel,
|
||||||
savedVoices,
|
savedVoices: config.savedVoices,
|
||||||
}, key, value);
|
}, key, value);
|
||||||
|
await updatePatch(syncPatch);
|
||||||
await updateAppConfig(storagePatch);
|
|
||||||
if (
|
|
||||||
key === 'voice' ||
|
|
||||||
key === 'providerRef' ||
|
|
||||||
key === 'providerType' ||
|
|
||||||
key === 'ttsModel' ||
|
|
||||||
key === 'savedVoices' ||
|
|
||||||
syncedPreferenceKeys.has(String(key))
|
|
||||||
) {
|
|
||||||
queueSyncedPreferencePatch(syncPatch);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error updating config key ${String(key)}:`, error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLoading = isSessionPending || query.isPending || mutation.isPending || sharedProvidersLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigContext.Provider value={{
|
<ConfigContext.Provider value={{
|
||||||
apiKey,
|
viewType: config.viewType,
|
||||||
baseUrl,
|
voiceSpeed: config.voiceSpeed,
|
||||||
viewType,
|
audioPlayerSpeed: config.audioPlayerSpeed,
|
||||||
voiceSpeed,
|
voice: config.voice,
|
||||||
audioPlayerSpeed,
|
skipBlank: config.skipBlank,
|
||||||
voice,
|
epubTheme: config.epubTheme,
|
||||||
skipBlank,
|
segmentPreloadDepthPages: config.segmentPreloadDepthPages,
|
||||||
epubTheme,
|
segmentPreloadSentenceLookahead: config.segmentPreloadSentenceLookahead,
|
||||||
segmentPreloadDepthPages,
|
ttsSegmentMaxBlockLength: config.ttsSegmentMaxBlockLength,
|
||||||
segmentPreloadSentenceLookahead,
|
headerMargin: config.headerMargin,
|
||||||
ttsSegmentMaxBlockLength,
|
footerMargin: config.footerMargin,
|
||||||
headerMargin,
|
leftMargin: config.leftMargin,
|
||||||
footerMargin,
|
rightMargin: config.rightMargin,
|
||||||
leftMargin,
|
|
||||||
rightMargin,
|
|
||||||
providerRef: effectiveProviderRef,
|
providerRef: effectiveProviderRef,
|
||||||
providerType,
|
providerType: effectiveProvider.providerType,
|
||||||
ttsModel: effectiveTtsModel,
|
ttsModel: effectiveTtsModel,
|
||||||
ttsInstructions: effectiveTtsInstructions,
|
ttsInstructions: effectiveTtsInstructions,
|
||||||
savedVoices,
|
savedVoices: config.savedVoices,
|
||||||
updateConfig,
|
|
||||||
updateConfigKey,
|
updateConfigKey,
|
||||||
isLoading,
|
isLoading,
|
||||||
isDBReady,
|
isDBReady: !isSessionPending && !query.isPending,
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled: config.pdfHighlightEnabled,
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled: config.pdfWordHighlightEnabled,
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled: config.epubHighlightEnabled,
|
||||||
epubWordHighlightEnabled,
|
epubWordHighlightEnabled: config.epubWordHighlightEnabled,
|
||||||
htmlHighlightEnabled,
|
htmlHighlightEnabled: config.htmlHighlightEnabled,
|
||||||
htmlWordHighlightEnabled,
|
htmlWordHighlightEnabled: config.htmlWordHighlightEnabled,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ConfigContext.Provider>
|
</ConfigContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook to consume the configuration context
|
|
||||||
* @returns {ConfigContextType} The configuration context value
|
|
||||||
* @throws {Error} When used outside of ConfigProvider
|
|
||||||
*/
|
|
||||||
export function useConfig() {
|
export function useConfig() {
|
||||||
const context = useContext(ConfigContext);
|
const context = useContext(ConfigContext);
|
||||||
if (context === undefined) {
|
if (!context) throw new Error('useConfig must be used within a ConfigProvider');
|
||||||
throw new Error('useConfig must be used within a ConfigProvider');
|
|
||||||
}
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, ReactNode } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, ReactNode } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { BaseDocument } from '@/types/documents';
|
import type { BaseDocument } from '@/types/documents';
|
||||||
import {
|
import {
|
||||||
deleteDocuments as deleteServerDocuments,
|
deleteDocuments as deleteServerDocuments,
|
||||||
|
|
@ -102,10 +102,31 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
return { pdfDocs, epubDocs, htmlDocs };
|
return { pdfDocs, epubDocs, htmlDocs };
|
||||||
}, [docs]);
|
}, [docs]);
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (files: File[]) => uploadServerDocuments(files),
|
||||||
|
onSuccess: (stored) => {
|
||||||
|
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous) =>
|
||||||
|
mergeStoredDocuments(previous, stored),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: documentsQueryKey }),
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteServerDocuments({ ids: [id] }),
|
||||||
|
onMutate: async (id) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: documentsQueryKey });
|
||||||
|
const previous = queryClient.getQueryData<SupportedDocument[]>(documentsQueryKey);
|
||||||
|
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (rows = []) => rows.filter((doc) => doc.id !== id));
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_error, _id, context) => queryClient.setQueryData(documentsQueryKey, context?.previous),
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: documentsQueryKey }),
|
||||||
|
});
|
||||||
|
|
||||||
const uploadDocuments = useCallback(async (files: File[]): Promise<BaseDocument[]> => {
|
const uploadDocuments = useCallback(async (files: File[]): Promise<BaseDocument[]> => {
|
||||||
if (files.length === 0) return [];
|
if (files.length === 0) return [];
|
||||||
|
|
||||||
const stored = await uploadServerDocuments(files);
|
const stored = await uploadMutation.mutateAsync(files);
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
stored.map(async (document, index) => {
|
stored.map(async (document, index) => {
|
||||||
const file = files[index];
|
const file = files[index];
|
||||||
|
|
@ -134,20 +155,13 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous) =>
|
|
||||||
mergeStoredDocuments(previous, stored),
|
|
||||||
);
|
|
||||||
|
|
||||||
return stored;
|
return stored;
|
||||||
}, [documentsQueryKey, queryClient]);
|
}, [uploadMutation]);
|
||||||
|
|
||||||
const deleteDocument = useCallback(async (id: string) => {
|
const deleteDocument = useCallback(async (id: string) => {
|
||||||
await deleteServerDocuments({ ids: [id] });
|
await deleteMutation.mutateAsync(id);
|
||||||
await evictCachedDocument(id);
|
await evictCachedDocument(id);
|
||||||
queryClient.setQueryData<SupportedDocument[]>(documentsQueryKey, (previous = []) =>
|
}, [deleteMutation]);
|
||||||
previous.filter((document) => document.id !== id),
|
|
||||||
);
|
|
||||||
}, [documentsQueryKey, queryClient]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentContext.Provider value={{
|
<DocumentContext.Provider value={{
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,15 @@
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal';
|
import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal';
|
||||||
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
|
|
||||||
import { PrivacyModal } from '@/components/PrivacyModal';
|
import { PrivacyModal } from '@/components/PrivacyModal';
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
||||||
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie';
|
|
||||||
import { listDocuments } from '@/lib/client/api/documents';
|
|
||||||
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
|
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
|
||||||
import { scheduleChangelogCheck } from '@/lib/client/changelog-check';
|
import { scheduleChangelogCheck } from '@/lib/client/changelog-check';
|
||||||
import { createCoalescedAsyncRunner, resolveNextOnboardingStep } from '@/lib/client/onboarding-flow';
|
import { createCoalescedAsyncRunner, resolveNextOnboardingStep } from '@/lib/client/onboarding-flow';
|
||||||
import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
|
import { useOnboardingState } from '@/hooks/useOnboardingState';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { queryKeys } from '@/lib/client/query-keys';
|
||||||
|
|
||||||
type OnboardingFlowContextValue = {
|
type OnboardingFlowContextValue = {
|
||||||
changelogOpenSignal: number;
|
changelogOpenSignal: number;
|
||||||
|
|
@ -19,18 +18,14 @@ type OnboardingFlowContextValue = {
|
||||||
|
|
||||||
const OnboardingFlowContext = createContext<OnboardingFlowContextValue | null>(null);
|
const OnboardingFlowContext = createContext<OnboardingFlowContextValue | null>(null);
|
||||||
|
|
||||||
type MigrationPromptState = {
|
|
||||||
shouldPrompt: boolean;
|
|
||||||
localCount: number;
|
|
||||||
missingCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_CLAIM_COUNTS: ClaimableCounts = {
|
const EMPTY_CLAIM_COUNTS: ClaimableCounts = {
|
||||||
documents: 0,
|
documents: 0,
|
||||||
audiobooks: 0,
|
audiobooks: 0,
|
||||||
preferences: 0,
|
preferences: 0,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
documentSettings: 0,
|
documentSettings: 0,
|
||||||
|
folders: 0,
|
||||||
|
onboarding: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function toClaimableCounts(value: unknown): ClaimableCounts {
|
function toClaimableCounts(value: unknown): ClaimableCounts {
|
||||||
|
|
@ -41,6 +36,8 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
|
||||||
preferences: Number(rec.preferences ?? 0),
|
preferences: Number(rec.preferences ?? 0),
|
||||||
progress: Number(rec.progress ?? 0),
|
progress: Number(rec.progress ?? 0),
|
||||||
documentSettings: Number(rec.documentSettings ?? 0),
|
documentSettings: Number(rec.documentSettings ?? 0),
|
||||||
|
folders: Number(rec.folders ?? 0),
|
||||||
|
onboarding: Number(rec.onboarding ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,71 +50,22 @@ async function fetchClaimableCounts(): Promise<ClaimableCounts> {
|
||||||
return toClaimableCounts(data);
|
return toClaimableCounts(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMigrationPromptState(privacyGateSatisfied: boolean): Promise<MigrationPromptState> {
|
|
||||||
const cfg = await getAppConfig();
|
|
||||||
if (!privacyGateSatisfied || cfg?.documentsMigrationPrompted) {
|
|
||||||
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [pdfs, epubs, htmls] = await Promise.all([
|
|
||||||
getAllPdfDocuments(),
|
|
||||||
getAllEpubDocuments(),
|
|
||||||
getAllHtmlDocuments(),
|
|
||||||
]);
|
|
||||||
const localDocs = [
|
|
||||||
...pdfs.map((d) => d.id),
|
|
||||||
...epubs.map((d) => d.id),
|
|
||||||
...htmls.map((d) => d.id),
|
|
||||||
];
|
|
||||||
const localCount = localDocs.length;
|
|
||||||
if (localCount === 0) {
|
|
||||||
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverDocs = await listDocuments().catch(() => null);
|
|
||||||
if (!serverDocs) {
|
|
||||||
return { shouldPrompt: true, localCount, missingCount: localCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverIds = new Set(serverDocs.map((d) => d.id));
|
|
||||||
const missingCount = localDocs.filter((id) => !serverIds.has(id)).length;
|
|
||||||
return {
|
|
||||||
shouldPrompt: missingCount > 0,
|
|
||||||
localCount,
|
|
||||||
missingCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocalOnboardingSnapshot = {
|
|
||||||
privacyAccepted: boolean;
|
|
||||||
firstVisitSettingsOpened: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function readLocalOnboardingSnapshot(): Promise<LocalOnboardingSnapshot> {
|
|
||||||
const appConfig = await getAppConfig();
|
|
||||||
const row = appConfig as Record<string, unknown> | null;
|
|
||||||
const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey;
|
|
||||||
const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey;
|
|
||||||
|
|
||||||
return {
|
|
||||||
privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false,
|
|
||||||
firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
const { data: session, isPending: isSessionPending } = useAuthSession();
|
const { data: session, isPending: isSessionPending } = useAuthSession();
|
||||||
const runtimeConfig = useRuntimeConfig();
|
const runtimeConfig = useRuntimeConfig();
|
||||||
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined;
|
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined;
|
||||||
const userId = user?.id ?? null;
|
const userId = user?.id ?? null;
|
||||||
const isAnonymous = Boolean(user?.isAnonymous);
|
const isAnonymous = Boolean(user?.isAnonymous);
|
||||||
|
const claimCountsQuery = useQuery({
|
||||||
const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | 'migration' | null>(null);
|
queryKey: queryKeys.claimCounts(userId ?? 'no-session'),
|
||||||
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>(EMPTY_CLAIM_COUNTS);
|
queryFn: fetchClaimableCounts,
|
||||||
const [migrationCounts, setMigrationCounts] = useState<{ localCount: number; missingCount: number }>({
|
enabled: Boolean(userId && !isAnonymous),
|
||||||
localCount: 0,
|
|
||||||
missingCount: 0,
|
|
||||||
});
|
});
|
||||||
|
const refetchClaimCounts = claimCountsQuery.refetch;
|
||||||
|
|
||||||
|
const { query: onboardingQuery } = useOnboardingState();
|
||||||
|
const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | null>(null);
|
||||||
|
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>(EMPTY_CLAIM_COUNTS);
|
||||||
const [changelogOpenSignal, setChangelogOpenSignal] = useState(0);
|
const [changelogOpenSignal, setChangelogOpenSignal] = useState(0);
|
||||||
|
|
||||||
const pendingChangelogOpenRef = useRef(false);
|
const pendingChangelogOpenRef = useRef(false);
|
||||||
|
|
@ -135,9 +83,17 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const runOnceFlow = useCallback(async () => {
|
const runOnceFlow = useCallback(async () => {
|
||||||
const local = await readLocalOnboardingSnapshot();
|
// Wait until the onboarding state has actually loaded before deciding whether
|
||||||
|
// to show the privacy modal. Otherwise the not-yet-loaded query (data === undefined)
|
||||||
|
// reads as "not accepted", the modal flashes on first paint, then closes once the
|
||||||
|
// real state arrives.
|
||||||
|
const onboardingData = onboardingQuery.data;
|
||||||
|
if (onboardingData === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const privacyRequired = true;
|
const privacyRequired = true;
|
||||||
const privacyAccepted = !privacyRequired || local.privacyAccepted;
|
const privacyAccepted = !privacyRequired || Boolean(onboardingData.privacyAcceptedAtMs);
|
||||||
|
|
||||||
const isClaimEligible = Boolean(
|
const isClaimEligible = Boolean(
|
||||||
userId
|
userId
|
||||||
|
|
@ -149,26 +105,25 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
let claimHasData = false;
|
let claimHasData = false;
|
||||||
|
|
||||||
if (isClaimEligible) {
|
if (isClaimEligible) {
|
||||||
claimCounts = await fetchClaimableCounts();
|
claimCounts = (await refetchClaimCounts()).data ?? EMPTY_CLAIM_COUNTS;
|
||||||
const total = claimCounts.documents
|
const total = claimCounts.documents
|
||||||
+ claimCounts.audiobooks
|
+ claimCounts.audiobooks
|
||||||
+ claimCounts.preferences
|
+ claimCounts.preferences
|
||||||
+ claimCounts.progress
|
+ claimCounts.progress
|
||||||
+ claimCounts.documentSettings;
|
+ claimCounts.documentSettings
|
||||||
|
+ claimCounts.folders
|
||||||
|
+ claimCounts.onboarding;
|
||||||
claimHasData = total > 0;
|
claimHasData = total > 0;
|
||||||
if (!claimHasData && userId) {
|
if (!claimHasData && userId) {
|
||||||
claimDismissedUsersRef.current.add(userId);
|
claimDismissedUsersRef.current.add(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const migrationState = await getMigrationPromptState(privacyAccepted);
|
|
||||||
|
|
||||||
const nextStep = resolveNextOnboardingStep({
|
const nextStep = resolveNextOnboardingStep({
|
||||||
privacyRequired,
|
privacyRequired,
|
||||||
privacyAccepted,
|
privacyAccepted,
|
||||||
claimEligible: isClaimEligible,
|
claimEligible: isClaimEligible,
|
||||||
claimHasData,
|
claimHasData,
|
||||||
migrationRequired: migrationState.shouldPrompt,
|
|
||||||
changelogPending: pendingChangelogOpenRef.current,
|
changelogPending: pendingChangelogOpenRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -183,26 +138,13 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextStep === 'migration') {
|
|
||||||
setMigrationCounts({
|
|
||||||
localCount: migrationState.localCount,
|
|
||||||
missingCount: migrationState.missingCount,
|
|
||||||
});
|
|
||||||
setActiveBlockingModal('migration');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveBlockingModal(null);
|
setActiveBlockingModal(null);
|
||||||
|
|
||||||
if (!local.firstVisitSettingsOpened) {
|
|
||||||
await setFirstVisit(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextStep === 'changelog') {
|
if (nextStep === 'changelog') {
|
||||||
pendingChangelogOpenRef.current = false;
|
pendingChangelogOpenRef.current = false;
|
||||||
setChangelogOpenSignal((value) => value + 1);
|
setChangelogOpenSignal((value) => value + 1);
|
||||||
}
|
}
|
||||||
}, [isAnonymous, userId]);
|
}, [isAnonymous, onboardingQuery.data, refetchClaimCounts, userId]);
|
||||||
|
|
||||||
runOnceFlowRef.current = runOnceFlow;
|
runOnceFlowRef.current = runOnceFlow;
|
||||||
|
|
||||||
|
|
@ -214,11 +156,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
void runFlow();
|
void runFlow();
|
||||||
}, [runFlow, userId]);
|
}, [runFlow, userId]);
|
||||||
|
|
||||||
const handleMigrationComplete = useCallback(() => {
|
|
||||||
setActiveBlockingModal(null);
|
|
||||||
void runFlow();
|
|
||||||
}, [runFlow]);
|
|
||||||
|
|
||||||
const handlePrivacyAccepted = useCallback(() => {
|
const handlePrivacyAccepted = useCallback(() => {
|
||||||
setActiveBlockingModal(null);
|
setActiveBlockingModal(null);
|
||||||
void runFlow();
|
void runFlow();
|
||||||
|
|
@ -226,7 +163,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void runFlow();
|
void runFlow();
|
||||||
}, [isAnonymous, runFlow, userId]);
|
}, [isAnonymous, onboardingQuery.data, runFlow, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPrivacyAccepted = () => {
|
const onPrivacyAccepted = () => {
|
||||||
|
|
@ -273,12 +210,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
onDismiss={handleClaimComplete}
|
onDismiss={handleClaimComplete}
|
||||||
onClaimed={handleClaimComplete}
|
onClaimed={handleClaimComplete}
|
||||||
/>
|
/>
|
||||||
<DexieMigrationModal
|
|
||||||
isOpen={activeBlockingModal === 'migration'}
|
|
||||||
localCount={migrationCounts.localCount}
|
|
||||||
missingCount={migrationCounts.missingCount}
|
|
||||||
onComplete={handleMigrationComplete}
|
|
||||||
/>
|
|
||||||
</OnboardingFlowContext.Provider>
|
</OnboardingFlowContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export function useFeatureFlag<K extends keyof RuntimeConfig>(key: K): RuntimeCo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronous accessor for modules that are loaded before the React tree
|
* Synchronous accessor for modules that are loaded before the React tree
|
||||||
* mounts (e.g. Dexie initialization, config defaults). Falls back to the
|
* mounts (for example config defaults). Falls back to the
|
||||||
* built-in defaults during SSR.
|
* built-in defaults during SSR.
|
||||||
*/
|
*/
|
||||||
export function readRuntimeConfigSync(): RuntimeConfig {
|
export function readRuntimeConfigSync(): RuntimeConfig {
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,7 @@ import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
||||||
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
||||||
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
||||||
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/client/dexie';
|
import { useDocumentProgress } from '@/hooks/useDocumentProgress';
|
||||||
import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
|
||||||
import { withRetry, ensureTtsSegments } from '@/lib/client/api/audiobooks';
|
import { withRetry, ensureTtsSegments } from '@/lib/client/api/audiobooks';
|
||||||
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
|
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
|
||||||
import {
|
import {
|
||||||
|
|
@ -82,6 +81,7 @@ import type {
|
||||||
TTSSegmentManifestItem,
|
TTSSegmentManifestItem,
|
||||||
} from '@/types/client';
|
} from '@/types/client';
|
||||||
import { isStableEpubLocator } from '@/types/client';
|
import { isStableEpubLocator } from '@/types/client';
|
||||||
|
import { clearCachedAudioObjectUrls, getCachedAudioUrl } from '@/lib/client/cache/audio';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub',
|
* Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub',
|
||||||
|
|
@ -420,8 +420,6 @@ const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||||
export function TTSProvider({ children }: { children: ReactNode }): ReactElement {
|
export function TTSProvider({ children }: { children: ReactNode }): ReactElement {
|
||||||
// Configuration context consumption
|
// Configuration context consumption
|
||||||
const {
|
const {
|
||||||
apiKey: openApiKey,
|
|
||||||
baseUrl: openApiBaseUrl,
|
|
||||||
isLoading: configIsLoading,
|
isLoading: configIsLoading,
|
||||||
voiceSpeed,
|
voiceSpeed,
|
||||||
audioPlayerSpeed,
|
audioPlayerSpeed,
|
||||||
|
|
@ -444,8 +442,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
// Audio and voice management hooks
|
// Audio and voice management hooks
|
||||||
const audioContext = useAudioContext();
|
const audioContext = useAudioContext();
|
||||||
const { availableVoices, fetchVoices } = useVoiceManagement(
|
const { availableVoices, fetchVoices } = useVoiceManagement(
|
||||||
openApiKey,
|
|
||||||
openApiBaseUrl,
|
|
||||||
configProviderRef,
|
configProviderRef,
|
||||||
configProviderType,
|
configProviderType,
|
||||||
configTTSModel,
|
configTTSModel,
|
||||||
|
|
@ -466,6 +462,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
if (Array.isArray(id)) return id[0];
|
if (Array.isArray(id)) return id[0];
|
||||||
return '';
|
return '';
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
const { query: progressQuery, schedule: scheduleProgress } = useDocumentProgress(documentId || undefined);
|
||||||
|
|
||||||
const currentReaderType: ReaderType = useMemo(() => {
|
const currentReaderType: ReaderType = useMemo(() => {
|
||||||
if (pathname.startsWith('/epub/')) return 'epub';
|
if (pathname.startsWith('/epub/')) return 'epub';
|
||||||
|
|
@ -729,6 +726,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
clearWarmAudioCache();
|
clearWarmAudioCache();
|
||||||
|
clearCachedAudioObjectUrls();
|
||||||
}, [clearWarmAudioCache]);
|
}, [clearWarmAudioCache]);
|
||||||
|
|
||||||
const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => {
|
const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => {
|
||||||
|
|
@ -1619,7 +1617,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setTTSModel(configTTSModel);
|
setTTSModel(configTTSModel);
|
||||||
setTTSInstructions(configTTSInstructions);
|
setTTSInstructions(configTTSInstructions);
|
||||||
}
|
}
|
||||||
}, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
|
}, [configIsLoading, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]);
|
||||||
|
|
||||||
const preloadGenerationSignatureRef = useRef<string>('');
|
const preloadGenerationSignatureRef = useRef<string>('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1766,12 +1764,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
const reqHeaders: TTSRequestHeaders = {
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-openai-key': openApiKey || '',
|
|
||||||
'x-tts-provider': configProviderRef,
|
'x-tts-provider': configProviderRef,
|
||||||
};
|
};
|
||||||
if (openApiBaseUrl) {
|
|
||||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryOptions: TTSRetryOptions = {
|
const retryOptions: TTSRetryOptions = {
|
||||||
maxRetries: 2,
|
maxRetries: 2,
|
||||||
|
|
@ -1914,8 +1908,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsModel,
|
ttsModel,
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
resolvedLanguage,
|
resolvedLanguage,
|
||||||
openApiKey,
|
|
||||||
openApiBaseUrl,
|
|
||||||
configProviderRef,
|
configProviderRef,
|
||||||
configProviderType,
|
configProviderType,
|
||||||
providerModelPolicy.supportsInstructions,
|
providerModelPolicy.supportsInstructions,
|
||||||
|
|
@ -2044,7 +2036,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setCurrentSentenceAlignment(playbackSource.manifest.alignment);
|
setCurrentSentenceAlignment(playbackSource.manifest.alignment);
|
||||||
setCurrentWordIndex(null);
|
setCurrentWordIndex(null);
|
||||||
}
|
}
|
||||||
const audioUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl;
|
const sourceUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl;
|
||||||
|
const audioUrl = await getCachedAudioUrl({
|
||||||
|
audioKey: playbackSource.manifest.segmentId,
|
||||||
|
version: playbackSource.manifest.durationMs,
|
||||||
|
primaryUrl: sourceUrl,
|
||||||
|
fallbackUrl: playbackSource.fallbackUrl,
|
||||||
|
}).catch(() => sourceUrl);
|
||||||
|
|
||||||
// Force unload any previous Howl instance to free up resources
|
// Force unload any previous Howl instance to free up resources
|
||||||
if (activeHowl) {
|
if (activeHowl) {
|
||||||
|
|
@ -2610,12 +2608,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
activeAbortControllers.current.add(controller);
|
activeAbortControllers.current.add(controller);
|
||||||
const reqHeaders: TTSRequestHeaders = {
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-openai-key': openApiKey || '',
|
|
||||||
'x-tts-provider': configProviderRef,
|
'x-tts-provider': configProviderRef,
|
||||||
};
|
};
|
||||||
if (openApiBaseUrl) {
|
|
||||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
|
||||||
}
|
|
||||||
const retryOptions: TTSRetryOptions = {
|
const retryOptions: TTSRetryOptions = {
|
||||||
maxRetries: 2,
|
maxRetries: 2,
|
||||||
initialDelay: 300,
|
initialDelay: 300,
|
||||||
|
|
@ -2903,12 +2897,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
activeAbortControllers.current.add(controller);
|
activeAbortControllers.current.add(controller);
|
||||||
const reqHeaders: TTSRequestHeaders = {
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-openai-key': openApiKey || '',
|
|
||||||
'x-tts-provider': configProviderRef,
|
'x-tts-provider': configProviderRef,
|
||||||
};
|
};
|
||||||
if (openApiBaseUrl) {
|
|
||||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryOptions: TTSRetryOptions = {
|
const retryOptions: TTSRetryOptions = {
|
||||||
maxRetries: 2,
|
maxRetries: 2,
|
||||||
|
|
@ -3026,8 +3016,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
configProviderRef,
|
configProviderRef,
|
||||||
configProviderType,
|
configProviderType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
openApiKey,
|
|
||||||
openApiBaseUrl,
|
|
||||||
providerModelPolicy.supportsInstructions,
|
providerModelPolicy.supportsInstructions,
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
resolvedLanguage,
|
resolvedLanguage,
|
||||||
|
|
@ -3397,13 +3385,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
skipBackward,
|
skipBackward,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load last location on mount for EPUB/PDF/HTML.
|
// Load the server-backed last location for EPUB/PDF/HTML.
|
||||||
// Prefer server-backed progress when available, then fall back to local Dexie.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id || !progressQuery.data?.location) return;
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const docId = id as string;
|
|
||||||
|
|
||||||
const applyLocation = (lastLocation: string) => {
|
const applyLocation = (lastLocation: string) => {
|
||||||
if (isEPUB && locationChangeHandlerRef.current) {
|
if (isEPUB && locationChangeHandlerRef.current) {
|
||||||
|
|
@ -3454,35 +3438,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = async () => {
|
applyLocation(progressQuery.data.location);
|
||||||
try {
|
}, [id, isEPUB, currentReaderType, progressQuery.data?.location]);
|
||||||
const local = await getLastDocumentLocation(docId);
|
|
||||||
if (!cancelled && local) {
|
|
||||||
applyLocation(local);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Error loading local last location:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const remote = await getDocumentProgress(docId);
|
|
||||||
if (!cancelled && remote?.location) {
|
|
||||||
await setLastDocumentLocation(docId, remote.location).catch((error) => {
|
|
||||||
console.warn('Error caching remote location locally:', error);
|
|
||||||
});
|
|
||||||
applyLocation(remote.location);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Error loading remote progress:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
load();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [id, isEPUB, currentReaderType]);
|
|
||||||
|
|
||||||
// Save current position periodically for non-EPUB readers.
|
// Save current position periodically for non-EPUB readers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -3491,10 +3448,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
? `html:${encodeURIComponent(String(currDocPage || 1))}:${currentIndex}`
|
? `html:${encodeURIComponent(String(currDocPage || 1))}:${currentIndex}`
|
||||||
: `${currDocPageNumber}:${currentIndex}`;
|
: `${currDocPageNumber}:${currentIndex}`;
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
setLastDocumentLocation(id as string, location).catch(error => {
|
scheduleProgress({
|
||||||
console.warn('Error saving non-EPUB location:', error);
|
|
||||||
});
|
|
||||||
scheduleDocumentProgressSync({
|
|
||||||
documentId: id as string,
|
documentId: id as string,
|
||||||
readerType: currentReaderType,
|
readerType: currentReaderType,
|
||||||
location,
|
location,
|
||||||
|
|
@ -3503,7 +3457,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType]);
|
}, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType, scheduleProgress]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the TTS context provider with its children
|
* Renders the TTS context provider with its children
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,12 @@ import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing TTS voices
|
* Custom hook for managing TTS voices
|
||||||
* @param apiKey OpenAI API key
|
|
||||||
* @param baseUrl OpenAI API base URL
|
|
||||||
* @param providerRef TTS provider routing reference (built-in id or shared slug)
|
* @param providerRef TTS provider routing reference (built-in id or shared slug)
|
||||||
* @param providerType Resolved provider type for capability/default logic
|
* @param providerType Resolved provider type for capability/default logic
|
||||||
* @param ttsModel TTS model name
|
* @param ttsModel TTS model name
|
||||||
* @returns Object containing available voices and fetch function
|
* @returns Object containing available voices and fetch function
|
||||||
*/
|
*/
|
||||||
export function useVoiceManagement(
|
export function useVoiceManagement(
|
||||||
apiKey: string | undefined,
|
|
||||||
baseUrl: string | undefined,
|
|
||||||
providerRef: string | undefined,
|
providerRef: string | undefined,
|
||||||
providerType: TtsProviderType | undefined,
|
providerType: TtsProviderType | undefined,
|
||||||
ttsModel: string | undefined
|
ttsModel: string | undefined
|
||||||
|
|
@ -29,8 +25,6 @@ export function useVoiceManagement(
|
||||||
try {
|
try {
|
||||||
console.log('Fetching voices...');
|
console.log('Fetching voices...');
|
||||||
const data = await getVoices({
|
const data = await getVoices({
|
||||||
'x-openai-key': apiKey || '',
|
|
||||||
'x-openai-base-url': baseUrl || '',
|
|
||||||
'x-tts-provider': providerRef || 'openai',
|
'x-tts-provider': providerRef || 'openai',
|
||||||
'x-tts-model': ttsModel || 'tts-1',
|
'x-tts-model': ttsModel || 'tts-1',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -56,7 +50,7 @@ export function useVoiceManagement(
|
||||||
model: ttsModel || 'tts-1',
|
model: ttsModel || 'tts-1',
|
||||||
}).defaultVoices);
|
}).defaultVoices);
|
||||||
}
|
}
|
||||||
}, [apiKey, baseUrl, providerRef, providerType, ttsModel]);
|
}, [providerRef, providerType, ttsModel]);
|
||||||
|
|
||||||
return { availableVoices, fetchVoices };
|
return { availableVoices, fetchVoices };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ export function filterNonEmptySpineTextEntries<T extends { text: string }>(entri
|
||||||
type UseEpubAudiobookParams = {
|
type UseEpubAudiobookParams = {
|
||||||
bookRef: RefObject<Book | null>;
|
bookRef: RefObject<Book | null>;
|
||||||
tocRef: RefObject<NavItem[]>;
|
tocRef: RefObject<NavItem[]>;
|
||||||
apiKey: string;
|
|
||||||
baseUrl: string;
|
|
||||||
providerRef: string;
|
providerRef: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -61,8 +59,6 @@ type UseEpubAudiobookResult = {
|
||||||
export function useEPUBAudiobook({
|
export function useEPUBAudiobook({
|
||||||
bookRef,
|
bookRef,
|
||||||
tocRef,
|
tocRef,
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
providerRef,
|
providerRef,
|
||||||
}: UseEpubAudiobookParams): UseEpubAudiobookResult {
|
}: UseEpubAudiobookParams): UseEpubAudiobookResult {
|
||||||
const loadSpineSection = useCallback(async (href: string) => {
|
const loadSpineSection = useCallback(async (href: string) => {
|
||||||
|
|
@ -131,8 +127,6 @@ export function useEPUBAudiobook({
|
||||||
try {
|
try {
|
||||||
return await runAudiobookGeneration({
|
return await runAudiobookGeneration({
|
||||||
adapter: audiobookAdapter,
|
adapter: audiobookAdapter,
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
defaultProvider: providerRef,
|
defaultProvider: providerRef,
|
||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
|
|
@ -145,7 +139,7 @@ export function useEPUBAudiobook({
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
}, [audiobookAdapter, providerRef]);
|
||||||
|
|
||||||
const regenerateChapter = useCallback(async (
|
const regenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
|
|
@ -161,8 +155,6 @@ export function useEPUBAudiobook({
|
||||||
bookId,
|
bookId,
|
||||||
format,
|
format,
|
||||||
signal,
|
signal,
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
defaultProvider: providerRef,
|
defaultProvider: providerRef,
|
||||||
settings,
|
settings,
|
||||||
});
|
});
|
||||||
|
|
@ -173,7 +165,7 @@ export function useEPUBAudiobook({
|
||||||
console.error('Error regenerating chapter:', error);
|
console.error('Error regenerating chapter:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
}, [audiobookAdapter, providerRef]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
|
||||||
import { db } from '@/lib/client/dexie';
|
|
||||||
import type { EPUBDocument } from '@/types/documents';
|
|
||||||
import { sha256HexFromArrayBuffer } from '@/lib/client/sha256';
|
|
||||||
|
|
||||||
export function useEPUBDocuments() {
|
|
||||||
const documents = useLiveQuery(
|
|
||||||
() => db['epub-documents'].toArray(),
|
|
||||||
[],
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoading = documents === undefined;
|
|
||||||
|
|
||||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const id = await sha256HexFromArrayBuffer(arrayBuffer);
|
|
||||||
|
|
||||||
console.log('Original file size:', file.size);
|
|
||||||
console.log('ArrayBuffer size:', arrayBuffer.byteLength);
|
|
||||||
|
|
||||||
const newDoc: EPUBDocument = {
|
|
||||||
id,
|
|
||||||
type: 'epub',
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
lastModified: file.lastModified,
|
|
||||||
data: arrayBuffer,
|
|
||||||
};
|
|
||||||
|
|
||||||
await db['epub-documents'].put(newDoc);
|
|
||||||
return id;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
|
||||||
await db['epub-documents'].delete(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearDocuments = useCallback(async (): Promise<void> => {
|
|
||||||
await db['epub-documents'].clear();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
documents: documents ?? [],
|
|
||||||
isLoading,
|
|
||||||
addDocument,
|
|
||||||
removeDocument,
|
|
||||||
clearDocuments,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -3,33 +3,14 @@
|
||||||
import { useCallback, type MutableRefObject, type RefObject } from 'react';
|
import { useCallback, type MutableRefObject, type RefObject } from 'react';
|
||||||
import type { Book, Rendition } from 'epubjs';
|
import type { Book, Rendition } from 'epubjs';
|
||||||
|
|
||||||
import { setLastDocumentLocation } from '@/lib/client/dexie';
|
import { useDocumentProgress } from '@/hooks/useDocumentProgress';
|
||||||
import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state';
|
|
||||||
|
|
||||||
type EpubLocation = string | number;
|
import {
|
||||||
|
isDirectionalEpubLocation,
|
||||||
export function isDirectionalEpubLocation(location: EpubLocation): location is 'next' | 'prev' {
|
shouldNavigateToDifferentCfi,
|
||||||
return location === 'next' || location === 'prev';
|
shouldPersistEpubLocation,
|
||||||
}
|
type EpubLocation,
|
||||||
|
} from '@/lib/client/epub/location-controller';
|
||||||
export function shouldNavigateToDifferentCfi(
|
|
||||||
location: EpubLocation,
|
|
||||||
currentStartCfi: string | undefined,
|
|
||||||
): location is string {
|
|
||||||
return (
|
|
||||||
typeof location === 'string'
|
|
||||||
&& !isDirectionalEpubLocation(location)
|
|
||||||
&& !!currentStartCfi
|
|
||||||
&& location !== currentStartCfi
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldPersistEpubLocation(
|
|
||||||
documentId: string | undefined,
|
|
||||||
previousLocation: EpubLocation,
|
|
||||||
): documentId is string {
|
|
||||||
return typeof documentId === 'string' && documentId.length > 0 && previousLocation !== 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UseEpubLocationControllerParams = {
|
type UseEpubLocationControllerParams = {
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
|
|
@ -54,6 +35,7 @@ export function useEPUBLocationController({
|
||||||
renditionRef,
|
renditionRef,
|
||||||
locationRef,
|
locationRef,
|
||||||
}: UseEpubLocationControllerParams): (location: EpubLocation) => void {
|
}: UseEpubLocationControllerParams): (location: EpubLocation) => void {
|
||||||
|
const { schedule: scheduleProgress } = useDocumentProgress(documentId);
|
||||||
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
|
const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => {
|
||||||
const book = bookRef.current;
|
const book = bookRef.current;
|
||||||
const rendition = renditionRef.current;
|
const rendition = renditionRef.current;
|
||||||
|
|
@ -133,10 +115,9 @@ export function useEPUBLocationController({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the location to IndexedDB if not initial
|
// Save the server-backed location after the first real rendition update.
|
||||||
if (shouldPersistEpubLocation(documentId, locationRef.current)) {
|
if (shouldPersistEpubLocation(documentId, locationRef.current)) {
|
||||||
setLastDocumentLocation(documentId, location.toString());
|
scheduleProgress({
|
||||||
scheduleDocumentProgressSync({
|
|
||||||
documentId,
|
documentId,
|
||||||
readerType: 'epub',
|
readerType: 'epub',
|
||||||
location: location.toString(),
|
location: location.toString(),
|
||||||
|
|
@ -160,8 +141,11 @@ export function useEPUBLocationController({
|
||||||
safeRenditionNavigate,
|
safeRenditionNavigate,
|
||||||
setIsEpub,
|
setIsEpub,
|
||||||
shouldPauseRef,
|
shouldPauseRef,
|
||||||
|
scheduleProgress,
|
||||||
skipToLocation,
|
skipToLocation,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return handleLocationChanged;
|
return handleLocationChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { isDirectionalEpubLocation, shouldNavigateToDifferentCfi, shouldPersistEpubLocation };
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
|
||||||
import { db } from '@/lib/client/dexie';
|
|
||||||
import type { HTMLDocument } from '@/types/documents';
|
|
||||||
import { sha256HexFromString } from '@/lib/client/sha256';
|
|
||||||
|
|
||||||
export function useHTMLDocuments() {
|
|
||||||
const documents = useLiveQuery(
|
|
||||||
() => db['html-documents'].toArray(),
|
|
||||||
[],
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoading = documents === undefined;
|
|
||||||
|
|
||||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
|
||||||
const buffer = await file.arrayBuffer();
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
const content = new TextDecoder().decode(bytes);
|
|
||||||
const id = await sha256HexFromString(content);
|
|
||||||
|
|
||||||
const newDoc: HTMLDocument = {
|
|
||||||
id,
|
|
||||||
type: 'html',
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
lastModified: file.lastModified,
|
|
||||||
data: content,
|
|
||||||
};
|
|
||||||
|
|
||||||
await db['html-documents'].put(newDoc);
|
|
||||||
return id;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
|
||||||
await db['html-documents'].delete(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearDocuments = useCallback(async (): Promise<void> => {
|
|
||||||
await db['html-documents'].clear();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
documents: documents ?? [],
|
|
||||||
isLoading,
|
|
||||||
addDocument,
|
|
||||||
removeDocument,
|
|
||||||
clearDocuments,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
|
||||||
import { db } from '@/lib/client/dexie';
|
|
||||||
import type { PDFDocument } from '@/types/documents';
|
|
||||||
import { sha256HexFromArrayBuffer } from '@/lib/client/sha256';
|
|
||||||
|
|
||||||
export function usePDFDocuments() {
|
|
||||||
const documents = useLiveQuery(
|
|
||||||
() => db['pdf-documents'].toArray(),
|
|
||||||
[],
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoading = documents === undefined;
|
|
||||||
|
|
||||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const id = await sha256HexFromArrayBuffer(arrayBuffer);
|
|
||||||
|
|
||||||
const newDoc: PDFDocument = {
|
|
||||||
id,
|
|
||||||
type: 'pdf',
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
lastModified: file.lastModified,
|
|
||||||
data: arrayBuffer,
|
|
||||||
};
|
|
||||||
|
|
||||||
await db['pdf-documents'].put(newDoc);
|
|
||||||
return id;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
|
||||||
await db['pdf-documents'].delete(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearDocuments = useCallback(async (): Promise<void> => {
|
|
||||||
await db['pdf-documents'].clear();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
documents: documents ?? [],
|
|
||||||
isLoading,
|
|
||||||
addDocument,
|
|
||||||
removeDocument,
|
|
||||||
clearDocuments,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +1,31 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { getDocumentSettings, putDocumentSettings } from '@/lib/client/api/documents';
|
|
||||||
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
|
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
|
||||||
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
|
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
|
||||||
|
import { useDocumentSettings } from '@/hooks/useDocumentSettings';
|
||||||
|
|
||||||
export function useDocumentLanguage(documentId: string | undefined): {
|
export function useDocumentLanguage(documentId: string | undefined): {
|
||||||
language: string;
|
language: string;
|
||||||
updateLanguage: (language: string) => Promise<void>;
|
updateLanguage: (language: string) => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const [settings, setSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
|
const { query, mutation } = useDocumentSettings(documentId);
|
||||||
|
const settings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, query.data?.settings);
|
||||||
useEffect(() => {
|
|
||||||
setSettings(DEFAULT_DOCUMENT_SETTINGS);
|
|
||||||
if (!documentId) return;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
void getDocumentSettings(documentId, { signal: controller.signal })
|
|
||||||
.then((response) => {
|
|
||||||
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
|
||||||
console.warn('Failed to load document language, using automatic detection:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [documentId]);
|
|
||||||
|
|
||||||
const updateLanguage = useCallback(async (language: string): Promise<void> => {
|
const updateLanguage = useCallback(async (language: string): Promise<void> => {
|
||||||
if (!documentId) return;
|
if (!documentId) return;
|
||||||
let next = DEFAULT_DOCUMENT_SETTINGS;
|
const next: DocumentSettings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
|
||||||
setSettings((prev) => {
|
...settings,
|
||||||
next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
|
|
||||||
...prev,
|
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
language,
|
language,
|
||||||
});
|
});
|
||||||
return next;
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const response = await putDocumentSettings(documentId, next);
|
await mutation.mutateAsync(next);
|
||||||
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to persist document language:', error);
|
console.warn('Failed to persist document language:', error);
|
||||||
}
|
}
|
||||||
}, [documentId]);
|
}, [documentId, mutation, settings]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
language: settings.language ?? 'auto',
|
language: settings.language ?? 'auto',
|
||||||
|
|
|
||||||
54
src/hooks/useDocumentProgress.ts
Normal file
54
src/hooks/useDocumentProgress.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getDocumentProgress, putDocumentProgress } from '@/lib/client/api/user-state';
|
||||||
|
import { queryKeys } from '@/lib/client/query-keys';
|
||||||
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
|
import type { DocumentProgressRecord, ReaderType } from '@/types/user-state';
|
||||||
|
|
||||||
|
export function useDocumentProgress(documentId: string | undefined) {
|
||||||
|
const { data: session, isPending } = useAuthSession();
|
||||||
|
const sessionId = session?.user?.id ?? 'no-session';
|
||||||
|
const key = queryKeys.progress(sessionId, documentId ?? '');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: ({ signal }) => getDocumentProgress(documentId!, { signal }),
|
||||||
|
enabled: !isPending && !!documentId,
|
||||||
|
});
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: putDocumentProgress,
|
||||||
|
onMutate: async (payload) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: key });
|
||||||
|
const previous = queryClient.getQueryData<DocumentProgressRecord | null>(key);
|
||||||
|
queryClient.setQueryData(key, {
|
||||||
|
documentId: payload.documentId,
|
||||||
|
readerType: payload.readerType,
|
||||||
|
location: payload.location,
|
||||||
|
progress: payload.progress ?? null,
|
||||||
|
clientUpdatedAtMs: Date.now(),
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_error, _payload, context) => queryClient.setQueryData(key, context?.previous),
|
||||||
|
onSuccess: (data) => queryClient.setQueryData(key, data),
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
|
||||||
|
});
|
||||||
|
const mutateProgress = mutation.mutate;
|
||||||
|
const schedule = useCallback((payload: {
|
||||||
|
documentId: string;
|
||||||
|
readerType: ReaderType;
|
||||||
|
location: string;
|
||||||
|
progress?: number | null;
|
||||||
|
}, debounceMs = 1000) => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
timer.current = setTimeout(() => mutateProgress(payload), debounceMs);
|
||||||
|
}, [mutateProgress]);
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
}, []);
|
||||||
|
return { query, mutation, schedule };
|
||||||
|
}
|
||||||
32
src/hooks/useDocumentSettings.ts
Normal file
32
src/hooks/useDocumentSettings.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getDocumentSettings, putDocumentSettings } from '@/lib/client/api/documents';
|
||||||
|
import { queryKeys } from '@/lib/client/query-keys';
|
||||||
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
|
import type { DocumentSettings } from '@/types/document-settings';
|
||||||
|
|
||||||
|
export function useDocumentSettings(documentId: string | undefined) {
|
||||||
|
const { data: session, isPending } = useAuthSession();
|
||||||
|
const sessionId = session?.user?.id ?? 'no-session';
|
||||||
|
const key = queryKeys.documentSettings(sessionId, documentId ?? '');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: ({ signal }) => getDocumentSettings(documentId!, { signal }),
|
||||||
|
enabled: !isPending && !!documentId,
|
||||||
|
});
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (settings: DocumentSettings) => putDocumentSettings(documentId!, settings),
|
||||||
|
onMutate: async (settings) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: key });
|
||||||
|
const previous = queryClient.getQueryData(key);
|
||||||
|
queryClient.setQueryData(key, { settings, clientUpdatedAtMs: Date.now(), hasStoredSettings: true });
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_error, _settings, context) => queryClient.setQueryData(key, context?.previous),
|
||||||
|
onSuccess: (data) => queryClient.setQueryData(key, data),
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
|
||||||
|
});
|
||||||
|
return { query, mutation };
|
||||||
|
}
|
||||||
103
src/hooks/useFolders.ts
Normal file
103
src/hooks/useFolders.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { queryKeys } from '@/lib/client/query-keys';
|
||||||
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
|
import type { BaseDocument } from '@/types/documents';
|
||||||
|
|
||||||
|
export type ServerFolder = { id: string; name: string; position: number; createdAt?: number; updatedAt?: number };
|
||||||
|
|
||||||
|
async function jsonRequest<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null) as { error?: string } | null;
|
||||||
|
throw new Error(data?.error || 'Folder request failed');
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFolders() {
|
||||||
|
const { data: session, isPending } = useAuthSession();
|
||||||
|
const sessionId = session?.user?.id ?? 'no-session';
|
||||||
|
const foldersKey = queryKeys.folders(sessionId);
|
||||||
|
const documentsKey = queryKeys.documents(sessionId);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: foldersKey,
|
||||||
|
queryFn: async ({ signal }) => (await jsonRequest<{ folders: ServerFolder[] }>('/api/folders', { signal })).folders,
|
||||||
|
enabled: !isPending,
|
||||||
|
});
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: (input: { id?: string; name: string; documentIds?: string[] }) => jsonRequest<{ folder: ServerFolder }>('/api/folders', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onSuccess: ({ folder }, input) => {
|
||||||
|
queryClient.setQueryData<ServerFolder[]>(foldersKey, (rows = []) => [...rows, folder]);
|
||||||
|
if (input.documentIds?.length) queryClient.setQueryData<BaseDocument[]>(documentsKey, (rows = []) =>
|
||||||
|
rows.map((doc) => input.documentIds!.includes(doc.id) ? { ...doc, folderId: folder.id } : doc));
|
||||||
|
},
|
||||||
|
onSettled: () => Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: foldersKey }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: documentsKey }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const move = useMutation({
|
||||||
|
mutationFn: (input: { documentIds: string[]; folderId: string | null }) => jsonRequest('/api/documents/folders', {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
onMutate: async (input) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: documentsKey });
|
||||||
|
const previous = queryClient.getQueryData<BaseDocument[]>(documentsKey);
|
||||||
|
queryClient.setQueryData<BaseDocument[]>(documentsKey, (rows = []) =>
|
||||||
|
rows.map((doc) => input.documentIds.includes(doc.id) ? { ...doc, folderId: input.folderId ?? undefined } : doc));
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_error, _input, context) => queryClient.setQueryData(documentsKey, context?.previous),
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: documentsKey }),
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => jsonRequest(`/api/folders/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
onMutate: async (id) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.cancelQueries({ queryKey: foldersKey }),
|
||||||
|
queryClient.cancelQueries({ queryKey: documentsKey }),
|
||||||
|
]);
|
||||||
|
const previousFolders = queryClient.getQueryData<ServerFolder[]>(foldersKey);
|
||||||
|
const previousDocuments = queryClient.getQueryData<BaseDocument[]>(documentsKey);
|
||||||
|
queryClient.setQueryData<ServerFolder[]>(foldersKey, (rows = []) => rows.filter((folder) => folder.id !== id));
|
||||||
|
queryClient.setQueryData<BaseDocument[]>(documentsKey, (rows = []) => rows.map((doc) => doc.folderId === id ? { ...doc, folderId: undefined } : doc));
|
||||||
|
return { previousFolders, previousDocuments };
|
||||||
|
},
|
||||||
|
onError: (_error, _id, context) => {
|
||||||
|
queryClient.setQueryData(foldersKey, context?.previousFolders);
|
||||||
|
queryClient.setQueryData(documentsKey, context?.previousDocuments);
|
||||||
|
},
|
||||||
|
onSettled: () => Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: foldersKey }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: documentsKey }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const clear = useMutation({
|
||||||
|
mutationFn: () => jsonRequest('/api/folders', { method: 'DELETE' }),
|
||||||
|
onMutate: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.cancelQueries({ queryKey: foldersKey }),
|
||||||
|
queryClient.cancelQueries({ queryKey: documentsKey }),
|
||||||
|
]);
|
||||||
|
const previousFolders = queryClient.getQueryData<ServerFolder[]>(foldersKey);
|
||||||
|
const previousDocuments = queryClient.getQueryData<BaseDocument[]>(documentsKey);
|
||||||
|
queryClient.setQueryData(foldersKey, []);
|
||||||
|
queryClient.setQueryData<BaseDocument[]>(documentsKey, (rows = []) => rows.map((doc) => ({ ...doc, folderId: undefined })));
|
||||||
|
return { previousFolders, previousDocuments };
|
||||||
|
},
|
||||||
|
onError: (_error, _input, context) => {
|
||||||
|
queryClient.setQueryData(foldersKey, context?.previousFolders);
|
||||||
|
queryClient.setQueryData(documentsKey, context?.previousDocuments);
|
||||||
|
},
|
||||||
|
onSettled: () => Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: foldersKey }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: documentsKey }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
return { query, create, move, remove, clear };
|
||||||
|
}
|
||||||
47
src/hooks/useOnboardingState.ts
Normal file
47
src/hooks/useOnboardingState.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { queryKeys } from '@/lib/client/query-keys';
|
||||||
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
|
|
||||||
|
export type OnboardingState = { privacyAcceptedAtMs: number | null; lastSeenAppVersion: string | null };
|
||||||
|
|
||||||
|
async function fetchOnboarding(signal?: AbortSignal): Promise<OnboardingState> {
|
||||||
|
const res = await fetch('/api/user/state/onboarding', { signal });
|
||||||
|
if (!res.ok) throw new Error('Failed to load onboarding state');
|
||||||
|
return ((await res.json()) as { onboarding: OnboardingState }).onboarding;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOnboardingState() {
|
||||||
|
const { data: session, isPending } = useAuthSession();
|
||||||
|
const sessionId = session?.user?.id ?? 'no-session';
|
||||||
|
const key = queryKeys.onboarding(sessionId);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const query = useQuery({ queryKey: key, queryFn: ({ signal }) => fetchOnboarding(signal), enabled: !isPending });
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (patch: { privacyAccepted?: boolean; lastSeenAppVersion?: string }) => {
|
||||||
|
const res = await fetch('/api/user/state/onboarding', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update onboarding state');
|
||||||
|
return ((await res.json()) as { onboarding: OnboardingState }).onboarding;
|
||||||
|
},
|
||||||
|
onMutate: async (patch) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: key });
|
||||||
|
const previous = queryClient.getQueryData<OnboardingState>(key);
|
||||||
|
queryClient.setQueryData<OnboardingState>(key, (current) => ({
|
||||||
|
privacyAcceptedAtMs: patch.privacyAccepted === undefined
|
||||||
|
? current?.privacyAcceptedAtMs ?? null
|
||||||
|
: patch.privacyAccepted ? Date.now() : null,
|
||||||
|
lastSeenAppVersion: patch.lastSeenAppVersion ?? current?.lastSeenAppVersion ?? null,
|
||||||
|
}));
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_error, _patch, context) => queryClient.setQueryData(key, context?.previous),
|
||||||
|
onSuccess: (data) => queryClient.setQueryData(key, data),
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
|
||||||
|
});
|
||||||
|
return { query, mutation };
|
||||||
|
}
|
||||||
29
src/hooks/useUserPreferences.ts
Normal file
29
src/hooks/useUserPreferences.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
|
||||||
|
import { queryKeys } from '@/lib/client/query-keys';
|
||||||
|
import type { SyncedPreferencesPatch } from '@/types/user-state';
|
||||||
|
|
||||||
|
export function useUserPreferences(sessionId: string, enabled: boolean) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const key = queryKeys.preferences(sessionId);
|
||||||
|
const query = useQuery({ queryKey: key, queryFn: ({ signal }) => getUserPreferences({ signal }), enabled });
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (patch: SyncedPreferencesPatch) => putUserPreferences(patch),
|
||||||
|
onMutate: async (patch) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: key });
|
||||||
|
const previous = queryClient.getQueryData<Awaited<ReturnType<typeof getUserPreferences>>>(key);
|
||||||
|
queryClient.setQueryData(key, {
|
||||||
|
preferences: { ...(previous?.preferences ?? {}), ...patch },
|
||||||
|
clientUpdatedAtMs: Date.now(),
|
||||||
|
hasStoredPreferences: true,
|
||||||
|
});
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_error, _patch, context) => queryClient.setQueryData(key, context?.previous),
|
||||||
|
onSuccess: (data) => queryClient.setQueryData(key, data),
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
|
||||||
|
});
|
||||||
|
return { query, mutation };
|
||||||
|
}
|
||||||
|
|
@ -85,7 +85,11 @@ export async function listDocuments(options?: { ids?: string[]; signal?: AbortSi
|
||||||
|
|
||||||
export async function getDocumentMetadata(id: string, options?: { signal?: AbortSignal }): Promise<BaseDocument | null> {
|
export async function getDocumentMetadata(id: string, options?: { signal?: AbortSignal }): Promise<BaseDocument | null> {
|
||||||
const docs = await listDocuments({ ids: [id], signal: options?.signal });
|
const docs = await listDocuments({ ids: [id], signal: options?.signal });
|
||||||
return docs[0] ?? null;
|
const document = docs[0] ?? null;
|
||||||
|
if (document) {
|
||||||
|
void fetch(`/api/documents/${encodeURIComponent(id)}/opened`, { method: 'PUT' }).catch(() => {});
|
||||||
|
}
|
||||||
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ParsedPdfNotReadyError extends Error {
|
export class ParsedPdfNotReadyError extends Error {
|
||||||
|
|
@ -439,9 +443,13 @@ export async function deleteDocuments(options?: { ids?: string[]; signal?: Abort
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadDocumentContent(id: string, options?: { signal?: AbortSignal }): Promise<ArrayBuffer> {
|
export async function downloadDocumentContent(id: string, options?: { signal?: AbortSignal }): Promise<ArrayBuffer> {
|
||||||
|
return (await fetchDocumentContentResponse(id, options)).arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDocumentContentResponse(id: string, options?: { signal?: AbortSignal }): Promise<Response> {
|
||||||
const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(id)}`;
|
const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(id)}`;
|
||||||
|
|
||||||
const fetchFallback = async (): Promise<ArrayBuffer> => {
|
const fetchFallback = async (): Promise<Response> => {
|
||||||
const res = await fetch(fallbackUrl, { signal: options?.signal });
|
const res = await fetch(fallbackUrl, { signal: options?.signal });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const contentType = res.headers.get('content-type') || '';
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
|
@ -451,7 +459,7 @@ export async function downloadDocumentContent(id: string, options?: { signal?: A
|
||||||
}
|
}
|
||||||
throw new Error(`Failed to download document (status ${res.status})`);
|
throw new Error(`Failed to download document (status ${res.status})`);
|
||||||
}
|
}
|
||||||
return res.arrayBuffer();
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -467,7 +475,7 @@ export async function downloadDocumentContent(id: string, options?: { signal?: A
|
||||||
}
|
}
|
||||||
throw new Error(`Failed to download document (status ${directRes.status})`);
|
throw new Error(`Failed to download document (status ${directRes.status})`);
|
||||||
}
|
}
|
||||||
return directRes.arrayBuffer();
|
return directRes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (options?.signal?.aborted) throw error;
|
if (options?.signal?.aborted) throw error;
|
||||||
return fetchFallback();
|
return fetchFallback();
|
||||||
|
|
@ -508,6 +516,7 @@ export type DocumentPreviewReady = {
|
||||||
fallbackUrl: string;
|
fallbackUrl: string;
|
||||||
presignUrl: string;
|
presignUrl: string;
|
||||||
directUrl?: string;
|
directUrl?: string;
|
||||||
|
previewVersion: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocumentPreviewStatus = DocumentPreviewPending | DocumentPreviewReady;
|
export type DocumentPreviewStatus = DocumentPreviewPending | DocumentPreviewReady;
|
||||||
|
|
@ -556,12 +565,14 @@ export async function getDocumentPreviewStatus(
|
||||||
fallbackUrl?: string;
|
fallbackUrl?: string;
|
||||||
presignUrl?: string;
|
presignUrl?: string;
|
||||||
directUrl?: string;
|
directUrl?: string;
|
||||||
|
previewVersion?: string;
|
||||||
} | null;
|
} | null;
|
||||||
return {
|
return {
|
||||||
kind: 'ready',
|
kind: 'ready',
|
||||||
fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id),
|
fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id),
|
||||||
presignUrl: data?.presignUrl || documentPreviewPresignUrl(id),
|
presignUrl: data?.presignUrl || documentPreviewPresignUrl(id),
|
||||||
directUrl: data?.directUrl,
|
directUrl: data?.directUrl,
|
||||||
|
previewVersion: data?.previewVersion || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -606,4 +617,3 @@ export async function importUrl(
|
||||||
|
|
||||||
return (await res.json()) as { title: string; content: string };
|
return (await res.json()) as { title: string; content: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,102 +78,6 @@ export async function postChangelogVersionCheck(
|
||||||
return (await res.json()) as ChangelogVersionCheckResponse;
|
return (await res.json()) as ChangelogVersionCheckResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingPreferenceSync = {
|
|
||||||
patch: SyncedPreferencesPatch;
|
|
||||||
timer: ReturnType<typeof setTimeout> | null;
|
|
||||||
sessionId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pendingPreferenceSync: PendingPreferenceSync = {
|
|
||||||
patch: {},
|
|
||||||
timer: null,
|
|
||||||
sessionId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let activeSyncController: AbortController | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel any pending debounced preference sync and abort in-flight requests.
|
|
||||||
* Call this on session change / sign-out to prevent cross-account writes.
|
|
||||||
*/
|
|
||||||
export function cancelPendingPreferenceSync(): void {
|
|
||||||
if (pendingPreferenceSync.timer) {
|
|
||||||
clearTimeout(pendingPreferenceSync.timer);
|
|
||||||
pendingPreferenceSync.timer = null;
|
|
||||||
}
|
|
||||||
pendingPreferenceSync.patch = {};
|
|
||||||
pendingPreferenceSync.sessionId = null;
|
|
||||||
|
|
||||||
if (activeSyncController) {
|
|
||||||
activeSyncController.abort();
|
|
||||||
activeSyncController = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function scheduleUserPreferencesSync(
|
|
||||||
patch: SyncedPreferencesPatch,
|
|
||||||
sessionId: string,
|
|
||||||
debounceMs: number = 600,
|
|
||||||
): void {
|
|
||||||
Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch));
|
|
||||||
pendingPreferenceSync.sessionId = sessionId;
|
|
||||||
|
|
||||||
if (pendingPreferenceSync.timer) {
|
|
||||||
clearTimeout(pendingPreferenceSync.timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const capturedSessionId = sessionId;
|
|
||||||
|
|
||||||
pendingPreferenceSync.timer = setTimeout(async () => {
|
|
||||||
// If the session changed between scheduling and firing, discard.
|
|
||||||
if (pendingPreferenceSync.sessionId !== capturedSessionId) return;
|
|
||||||
|
|
||||||
const payload = { ...pendingPreferenceSync.patch };
|
|
||||||
pendingPreferenceSync.patch = {};
|
|
||||||
pendingPreferenceSync.timer = null;
|
|
||||||
if (Object.keys(payload).length === 0) return;
|
|
||||||
|
|
||||||
// Abort any previous in-flight sync and create a fresh controller.
|
|
||||||
if (activeSyncController) activeSyncController.abort();
|
|
||||||
activeSyncController = new AbortController();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await putUserPreferences(payload, { signal: activeSyncController.signal });
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as Error)?.name === 'AbortError') return;
|
|
||||||
console.warn('Failed to sync user preferences:', error);
|
|
||||||
} finally {
|
|
||||||
activeSyncController = null;
|
|
||||||
}
|
|
||||||
}, debounceMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immediately send any pending debounced preference patch and await the result.
|
|
||||||
* Use this when the user explicitly saves: we want the server write to complete
|
|
||||||
* (and surface failures) instead of silently relying on the debounce timer, which
|
|
||||||
* can be lost if the page is reloaded inside the debounce window.
|
|
||||||
*/
|
|
||||||
export async function flushUserPreferencesSync(): Promise<void> {
|
|
||||||
if (pendingPreferenceSync.timer) {
|
|
||||||
clearTimeout(pendingPreferenceSync.timer);
|
|
||||||
pendingPreferenceSync.timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = { ...pendingPreferenceSync.patch };
|
|
||||||
pendingPreferenceSync.patch = {};
|
|
||||||
if (Object.keys(payload).length === 0) return;
|
|
||||||
|
|
||||||
if (activeSyncController) activeSyncController.abort();
|
|
||||||
activeSyncController = new AbortController();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await putUserPreferences(payload, { signal: activeSyncController.signal });
|
|
||||||
} finally {
|
|
||||||
activeSyncController = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDocumentProgress(
|
export async function getDocumentProgress(
|
||||||
documentId: string,
|
documentId: string,
|
||||||
options?: { signal?: AbortSignal },
|
options?: { signal?: AbortSignal },
|
||||||
|
|
@ -216,56 +120,3 @@ export async function putDocumentProgress(payload: {
|
||||||
const data = (await res.json()) as ProgressResponse;
|
const data = (await res.json()) as ProgressResponse;
|
||||||
return data.progress ?? null;
|
return data.progress ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingProgressSync = {
|
|
||||||
payload: {
|
|
||||||
documentId: string;
|
|
||||||
readerType: ReaderType;
|
|
||||||
location: string;
|
|
||||||
progress: number | null;
|
|
||||||
};
|
|
||||||
timer: ReturnType<typeof setTimeout> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pendingProgressByDoc = new Map<string, PendingProgressSync>();
|
|
||||||
|
|
||||||
export function scheduleDocumentProgressSync(
|
|
||||||
payload: {
|
|
||||||
documentId: string;
|
|
||||||
readerType: ReaderType;
|
|
||||||
location: string;
|
|
||||||
progress?: number | null;
|
|
||||||
},
|
|
||||||
debounceMs: number = 1000,
|
|
||||||
): void {
|
|
||||||
const existing = pendingProgressByDoc.get(payload.documentId);
|
|
||||||
if (existing?.timer) {
|
|
||||||
clearTimeout(existing.timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const next: PendingProgressSync = {
|
|
||||||
payload: {
|
|
||||||
documentId: payload.documentId,
|
|
||||||
readerType: payload.readerType,
|
|
||||||
location: payload.location,
|
|
||||||
progress: payload.progress ?? null,
|
|
||||||
},
|
|
||||||
timer: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
next.timer = setTimeout(async () => {
|
|
||||||
pendingProgressByDoc.delete(payload.documentId);
|
|
||||||
try {
|
|
||||||
await putDocumentProgress({
|
|
||||||
documentId: next.payload.documentId,
|
|
||||||
readerType: next.payload.readerType,
|
|
||||||
location: next.payload.location,
|
|
||||||
progress: next.payload.progress,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to sync document progress:', error);
|
|
||||||
}
|
|
||||||
}, debounceMs);
|
|
||||||
|
|
||||||
pendingProgressByDoc.set(payload.documentId, next);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ export interface AudiobookSourceAdapter {
|
||||||
|
|
||||||
interface RunAudiobookGenerationOptions {
|
interface RunAudiobookGenerationOptions {
|
||||||
adapter: AudiobookSourceAdapter;
|
adapter: AudiobookSourceAdapter;
|
||||||
apiKey: string;
|
|
||||||
baseUrl: string;
|
|
||||||
defaultProvider: string;
|
defaultProvider: string;
|
||||||
onProgress: (progress: number) => void;
|
onProgress: (progress: number) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
|
@ -47,8 +45,6 @@ interface RegenerateAudiobookChapterOptions {
|
||||||
bookId: string;
|
bookId: string;
|
||||||
format: TTSAudiobookFormat;
|
format: TTSAudiobookFormat;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
apiKey: string;
|
|
||||||
baseUrl: string;
|
|
||||||
defaultProvider: string;
|
defaultProvider: string;
|
||||||
settings?: AudiobookGenerationSettings;
|
settings?: AudiobookGenerationSettings;
|
||||||
retryOptions?: TTSRetryOptions;
|
retryOptions?: TTSRetryOptions;
|
||||||
|
|
@ -71,14 +67,10 @@ function resolveAudiobookRequestSettings(
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAudiobookRequestHeaders(
|
function buildAudiobookRequestHeaders(
|
||||||
apiKey: string,
|
|
||||||
baseUrl: string,
|
|
||||||
effectiveProvider: string,
|
effectiveProvider: string,
|
||||||
): TTSRequestHeaders {
|
): TTSRequestHeaders {
|
||||||
return {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-openai-key': apiKey,
|
|
||||||
'x-openai-base-url': baseUrl,
|
|
||||||
'x-tts-provider': effectiveProvider,
|
'x-tts-provider': effectiveProvider,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -95,8 +87,6 @@ function createAudiobookAbortError(): Error {
|
||||||
|
|
||||||
export async function runAudiobookGeneration({
|
export async function runAudiobookGeneration({
|
||||||
adapter,
|
adapter,
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
|
|
@ -120,7 +110,7 @@ export async function runAudiobookGeneration({
|
||||||
}
|
}
|
||||||
|
|
||||||
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
|
const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef);
|
||||||
let processedLength = 0;
|
let processedLength = 0;
|
||||||
let bookId = providedBookId;
|
let bookId = providedBookId;
|
||||||
|
|
||||||
|
|
@ -221,8 +211,6 @@ export async function regenerateAudiobookChapter({
|
||||||
bookId,
|
bookId,
|
||||||
format,
|
format,
|
||||||
signal,
|
signal,
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
settings,
|
settings,
|
||||||
retryOptions = {
|
retryOptions = {
|
||||||
|
|
@ -238,7 +226,7 @@ export async function regenerateAudiobookChapter({
|
||||||
}
|
}
|
||||||
|
|
||||||
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
|
const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef);
|
||||||
|
|
||||||
return withRetry(
|
return withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
|
|
||||||
28
src/lib/client/cache/audio.ts
vendored
Normal file
28
src/lib/client/cache/audio.ts
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { audioBlobCacheKey, getCachedBlob } from '@/lib/client/cache/blob-cache';
|
||||||
|
|
||||||
|
const objectUrls = new Map<string, string>();
|
||||||
|
|
||||||
|
export async function getCachedAudioUrl(input: {
|
||||||
|
audioKey: string;
|
||||||
|
version: string | number;
|
||||||
|
primaryUrl: string | null;
|
||||||
|
fallbackUrl: string | null;
|
||||||
|
}): Promise<string> {
|
||||||
|
const key = audioBlobCacheKey(input.audioKey, input.version);
|
||||||
|
const existing = objectUrls.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const response = await getCachedBlob(key, async () => {
|
||||||
|
const primary = input.primaryUrl ? await fetch(input.primaryUrl).catch(() => null) : null;
|
||||||
|
if (primary?.ok) return primary;
|
||||||
|
if (!input.fallbackUrl) return primary ?? new Response(null, { status: 404 });
|
||||||
|
return fetch(input.fallbackUrl);
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(await response.blob());
|
||||||
|
objectUrls.set(key, url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCachedAudioObjectUrls(): void {
|
||||||
|
for (const url of objectUrls.values()) URL.revokeObjectURL(url);
|
||||||
|
objectUrls.clear();
|
||||||
|
}
|
||||||
60
src/lib/client/cache/blob-cache.ts
vendored
Normal file
60
src/lib/client/cache/blob-cache.ts
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
const BLOB_CACHE_NAME = 'openreader-blobs-v1';
|
||||||
|
|
||||||
|
function canUseCacheStorage(): boolean {
|
||||||
|
return typeof window !== 'undefined' && typeof caches !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedBlob(
|
||||||
|
stableKey: string,
|
||||||
|
fetchSource: () => Promise<Response>,
|
||||||
|
): Promise<Response> {
|
||||||
|
let cache: Cache | null = null;
|
||||||
|
if (canUseCacheStorage()) {
|
||||||
|
try {
|
||||||
|
cache = await caches.open(BLOB_CACHE_NAME);
|
||||||
|
const cached = await cache.match(stableKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
} catch {
|
||||||
|
cache = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchSource();
|
||||||
|
if (!response.ok) throw new Error(`Blob fetch failed: ${response.status}`);
|
||||||
|
|
||||||
|
if (cache && response.status === 200 && response.type !== 'opaque' && !response.headers.has('Content-Range')) {
|
||||||
|
await cache.put(stableKey, response.clone()).catch(() => {});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putCachedBlob(stableKey: string, response: Response): Promise<void> {
|
||||||
|
if (!canUseCacheStorage() || !response.ok || response.status !== 200 || response.type === 'opaque') return;
|
||||||
|
const cache = await caches.open(BLOB_CACHE_NAME).catch(() => null);
|
||||||
|
await cache?.put(stableKey, response.clone()).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evictCachedBlobPrefix(prefix: string): Promise<void> {
|
||||||
|
if (!canUseCacheStorage()) return;
|
||||||
|
const cache = await caches.open(BLOB_CACHE_NAME).catch(() => null);
|
||||||
|
if (!cache) return;
|
||||||
|
const keys = await cache.keys().catch(() => []);
|
||||||
|
await Promise.allSettled(keys.filter((request) => new URL(request.url).pathname.startsWith(prefix)).map((request) => cache.delete(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearBlobCache(): Promise<void> {
|
||||||
|
if (!canUseCacheStorage()) return;
|
||||||
|
await caches.delete(BLOB_CACHE_NAME).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function documentBlobCacheKey(documentId: string, contentVersion: string): string {
|
||||||
|
return `/openreader-cache/documents/${encodeURIComponent(documentId)}/${encodeURIComponent(contentVersion)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewBlobCacheKey(documentId: string, previewVersion: string | number): string {
|
||||||
|
return `/openreader-cache/previews/${encodeURIComponent(documentId)}/${encodeURIComponent(String(previewVersion))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioBlobCacheKey(audioKey: string, version: string | number): string {
|
||||||
|
return `/openreader-cache/audio/${encodeURIComponent(audioKey)}/${encodeURIComponent(String(version))}`;
|
||||||
|
}
|
||||||
176
src/lib/client/cache/documents.ts
vendored
176
src/lib/client/cache/documents.ts
vendored
|
|
@ -1,152 +1,50 @@
|
||||||
import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '@/types/documents';
|
import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '@/types/documents';
|
||||||
import { downloadDocumentContent } from '@/lib/client/api/documents';
|
import { fetchDocumentContentResponse } from '@/lib/client/api/documents';
|
||||||
|
import {
|
||||||
|
clearBlobCache,
|
||||||
|
documentBlobCacheKey,
|
||||||
|
evictCachedBlobPrefix,
|
||||||
|
getCachedBlob,
|
||||||
|
putCachedBlob,
|
||||||
|
} from '@/lib/client/cache/blob-cache';
|
||||||
|
|
||||||
export type DocumentCacheBackend = {
|
function stableKey(meta: BaseDocument): string {
|
||||||
get: (meta: BaseDocument) => Promise<PDFDocument | EPUBDocument | HTMLDocument | null>;
|
return documentBlobCacheKey(meta.id, meta.contentVersion || meta.id);
|
||||||
putPdf: (meta: BaseDocument, data: ArrayBuffer) => Promise<void>;
|
}
|
||||||
putEpub: (meta: BaseDocument, data: ArrayBuffer) => Promise<void>;
|
|
||||||
putHtml: (meta: BaseDocument, data: string) => Promise<void>;
|
|
||||||
download: (id: string, options?: { signal?: AbortSignal }) => Promise<ArrayBuffer>;
|
|
||||||
decodeText: (buffer: ArrayBuffer) => string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function ensureCachedDocumentCore(
|
async function readDocument(meta: BaseDocument, response: Response): Promise<PDFDocument | EPUBDocument | HTMLDocument> {
|
||||||
|
if (meta.type === 'html') {
|
||||||
|
return { ...meta, type: 'html', data: await response.text() };
|
||||||
|
}
|
||||||
|
const data = await response.arrayBuffer();
|
||||||
|
if (meta.type === 'epub') return { ...meta, type: 'epub', data };
|
||||||
|
return { ...meta, type: 'pdf', data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCachedDocument(
|
||||||
meta: BaseDocument,
|
meta: BaseDocument,
|
||||||
backend: DocumentCacheBackend,
|
|
||||||
options?: { signal?: AbortSignal },
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<PDFDocument | EPUBDocument | HTMLDocument> {
|
): Promise<PDFDocument | EPUBDocument | HTMLDocument> {
|
||||||
const cached = await backend.get(meta);
|
if (meta.type !== 'pdf' && meta.type !== 'epub' && meta.type !== 'html') {
|
||||||
if (cached) return cached;
|
throw new Error(`Unsupported cached document type: ${meta.type}`);
|
||||||
|
|
||||||
const buffer = await backend.download(meta.id, { signal: options?.signal });
|
|
||||||
|
|
||||||
if (meta.type === 'pdf') {
|
|
||||||
await backend.putPdf(meta, buffer);
|
|
||||||
const after = await backend.get(meta);
|
|
||||||
if (!after || after.type !== 'pdf') throw new Error('Failed to cache PDF');
|
|
||||||
return after;
|
|
||||||
}
|
}
|
||||||
|
const response = await getCachedBlob(stableKey(meta), () => fetchDocumentContentResponse(meta.id, options));
|
||||||
if (meta.type === 'epub') {
|
return readDocument(meta, response);
|
||||||
await backend.putEpub(meta, buffer);
|
|
||||||
const after = await backend.get(meta);
|
|
||||||
if (!after || after.type !== 'epub') throw new Error('Failed to cache EPUB');
|
|
||||||
return after;
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoded = backend.decodeText(buffer);
|
|
||||||
await backend.putHtml(meta, decoded);
|
|
||||||
const after = await backend.get(meta);
|
|
||||||
if (!after || after.type !== 'html') throw new Error('Failed to cache HTML');
|
|
||||||
return after;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCachedPdf(id: string): Promise<PDFDocument | null> {
|
|
||||||
const { getPdfDocument } = await import('@/lib/client/dexie');
|
|
||||||
return (await getPdfDocument(id)) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function putCachedPdf(meta: BaseDocument, data: ArrayBuffer): Promise<void> {
|
|
||||||
const { addPdfDocument } = await import('@/lib/client/dexie');
|
|
||||||
await addPdfDocument({
|
|
||||||
id: meta.id,
|
|
||||||
type: 'pdf',
|
|
||||||
name: meta.name,
|
|
||||||
size: meta.size,
|
|
||||||
lastModified: meta.lastModified,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function evictCachedPdf(id: string): Promise<void> {
|
|
||||||
const { removePdfDocument } = await import('@/lib/client/dexie');
|
|
||||||
await removePdfDocument(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCachedEpub(id: string): Promise<EPUBDocument | null> {
|
|
||||||
const { getEpubDocument } = await import('@/lib/client/dexie');
|
|
||||||
return (await getEpubDocument(id)) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function putCachedEpub(meta: BaseDocument, data: ArrayBuffer): Promise<void> {
|
|
||||||
const { addEpubDocument } = await import('@/lib/client/dexie');
|
|
||||||
await addEpubDocument({
|
|
||||||
id: meta.id,
|
|
||||||
type: 'epub',
|
|
||||||
name: meta.name,
|
|
||||||
size: meta.size,
|
|
||||||
lastModified: meta.lastModified,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function evictCachedEpub(id: string): Promise<void> {
|
|
||||||
const { removeEpubDocument } = await import('@/lib/client/dexie');
|
|
||||||
await removeEpubDocument(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCachedHtml(id: string): Promise<HTMLDocument | null> {
|
|
||||||
const { getHtmlDocument } = await import('@/lib/client/dexie');
|
|
||||||
return (await getHtmlDocument(id)) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function putCachedHtml(meta: BaseDocument, data: string): Promise<void> {
|
|
||||||
const { addHtmlDocument } = await import('@/lib/client/dexie');
|
|
||||||
await addHtmlDocument({
|
|
||||||
id: meta.id,
|
|
||||||
type: 'html',
|
|
||||||
name: meta.name,
|
|
||||||
size: meta.size,
|
|
||||||
lastModified: meta.lastModified,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function evictCachedHtml(id: string): Promise<void> {
|
|
||||||
const { removeHtmlDocument } = await import('@/lib/client/dexie');
|
|
||||||
await removeHtmlDocument(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function evictCachedDocument(id: string): Promise<void> {
|
|
||||||
await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearDocumentCache(): Promise<void> {
|
|
||||||
const { clearPdfDocuments, clearEpubDocuments, clearHtmlDocuments } = await import('@/lib/client/dexie');
|
|
||||||
await Promise.all([clearPdfDocuments(), clearEpubDocuments(), clearHtmlDocuments()]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cacheStoredDocumentFromBytes(stored: BaseDocument, bytes: ArrayBuffer): Promise<void> {
|
export async function cacheStoredDocumentFromBytes(stored: BaseDocument, bytes: ArrayBuffer): Promise<void> {
|
||||||
if (stored.type === 'pdf') {
|
const type = stored.type === 'pdf'
|
||||||
await putCachedPdf(stored, bytes);
|
? 'application/pdf'
|
||||||
return;
|
: stored.type === 'epub'
|
||||||
}
|
? 'application/epub+zip'
|
||||||
if (stored.type === 'epub') {
|
: 'text/plain; charset=utf-8';
|
||||||
await putCachedEpub(stored, bytes);
|
await putCachedBlob(stableKey(stored), new Response(bytes, { status: 200, headers: { 'Content-Type': type } }));
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (stored.type === 'html') {
|
|
||||||
const decoded = new TextDecoder().decode(new Uint8Array(bytes));
|
|
||||||
await putCachedHtml(stored, decoded);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureCachedDocument(meta: BaseDocument, options?: { signal?: AbortSignal }): Promise<PDFDocument | EPUBDocument | HTMLDocument> {
|
export async function evictCachedDocument(id: string): Promise<void> {
|
||||||
return ensureCachedDocumentCore(
|
await evictCachedBlobPrefix(`/openreader-cache/documents/${encodeURIComponent(id)}/`);
|
||||||
meta,
|
}
|
||||||
{
|
|
||||||
get: async (m) => {
|
export async function clearDocumentCache(): Promise<void> {
|
||||||
const { getPdfDocument, getEpubDocument, getHtmlDocument } = await import('@/lib/client/dexie');
|
await clearBlobCache();
|
||||||
if (m.type === 'pdf') return (await getPdfDocument(m.id)) ?? null;
|
|
||||||
if (m.type === 'epub') return (await getEpubDocument(m.id)) ?? null;
|
|
||||||
return (await getHtmlDocument(m.id)) ?? null;
|
|
||||||
},
|
|
||||||
putPdf: putCachedPdf,
|
|
||||||
putEpub: putCachedEpub,
|
|
||||||
putHtml: putCachedHtml,
|
|
||||||
download: downloadDocumentContent,
|
|
||||||
decodeText: (buffer) => new TextDecoder().decode(new Uint8Array(buffer)),
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
src/lib/client/cache/previews.ts
vendored
138
src/lib/client/cache/previews.ts
vendored
|
|
@ -1,24 +1,11 @@
|
||||||
import {
|
|
||||||
clearDocumentPreviewCache as clearPersistedDocumentPreviewCache,
|
|
||||||
getDocumentPreviewCache,
|
|
||||||
putDocumentPreviewCache,
|
|
||||||
removeDocumentPreviewCache,
|
|
||||||
} from '@/lib/client/dexie';
|
|
||||||
import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/client/api/documents';
|
import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/client/api/documents';
|
||||||
|
import { clearBlobCache, getCachedBlob, previewBlobCacheKey } from '@/lib/client/cache/blob-cache';
|
||||||
|
|
||||||
const inMemoryPreviewUrlCache = new Map<string, string>();
|
const inMemoryPreviewUrlCache = new Map<string, string>();
|
||||||
const inFlightPreviewPrime = new Map<string, Promise<string | null>>();
|
const inFlightPreviewPrime = new Map<string, Promise<string | null>>();
|
||||||
const inFlightPersistedPreviewUrl = new Map<string, Promise<string | null>>();
|
|
||||||
const PREVIEW_CACHE_SCHEMA_VERSION = 4;
|
|
||||||
|
|
||||||
function revokeIfBlobUrl(url: string | null | undefined): void {
|
function revokeIfBlobUrl(url: string | null | undefined): void {
|
||||||
if (!url) return;
|
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||||
if (!url.startsWith('blob:')) return;
|
|
||||||
try {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null {
|
export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null {
|
||||||
|
|
@ -27,131 +14,62 @@ export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null {
|
||||||
|
|
||||||
export function setInMemoryDocumentPreviewUrl(cacheKey: string, url: string): void {
|
export function setInMemoryDocumentPreviewUrl(cacheKey: string, url: string): void {
|
||||||
const prev = inMemoryPreviewUrlCache.get(cacheKey);
|
const prev = inMemoryPreviewUrlCache.get(cacheKey);
|
||||||
if (prev && prev !== url) {
|
if (prev && prev !== url) revokeIfBlobUrl(prev);
|
||||||
revokeIfBlobUrl(prev);
|
|
||||||
}
|
|
||||||
inMemoryPreviewUrlCache.set(cacheKey, url);
|
inMemoryPreviewUrlCache.set(cacheKey, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearInMemoryDocumentPreviewCache(): void {
|
export function clearInMemoryDocumentPreviewCache(): void {
|
||||||
for (const value of inMemoryPreviewUrlCache.values()) {
|
for (const value of inMemoryPreviewUrlCache.values()) revokeIfBlobUrl(value);
|
||||||
revokeIfBlobUrl(value);
|
|
||||||
}
|
|
||||||
inMemoryPreviewUrlCache.clear();
|
inMemoryPreviewUrlCache.clear();
|
||||||
inFlightPersistedPreviewUrl.clear();
|
}
|
||||||
|
|
||||||
|
async function fetchPreviewSource(docId: string, signal?: AbortSignal): Promise<Response> {
|
||||||
|
const options = { signal, cache: 'no-store' as const };
|
||||||
|
const direct = await fetch(documentPreviewPresignUrl(docId), options).catch(() => null);
|
||||||
|
if (direct?.ok) return direct;
|
||||||
|
return fetch(documentPreviewFallbackUrl(docId), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPersistedDocumentPreviewUrl(
|
export async function getPersistedDocumentPreviewUrl(
|
||||||
docId: string,
|
docId: string,
|
||||||
lastModified: number,
|
previewVersion: string | number,
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const cachedUrl = getInMemoryDocumentPreviewUrl(cacheKey);
|
return primeDocumentPreviewCache(docId, previewVersion, cacheKey);
|
||||||
if (cachedUrl) return cachedUrl;
|
|
||||||
|
|
||||||
const persistedKey = `${cacheKey}:${Number(lastModified)}`;
|
|
||||||
const existing = inFlightPersistedPreviewUrl.get(persistedKey);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = (async (): Promise<string | null> => {
|
|
||||||
const row = await getDocumentPreviewCache(docId);
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
if (Number((row as { previewVersion?: number }).previewVersion ?? 1) !== PREVIEW_CACHE_SCHEMA_VERSION) {
|
|
||||||
await removeDocumentPreviewCache(docId).catch(() => {});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number(row.lastModified) !== Number(lastModified)) {
|
|
||||||
await removeDocumentPreviewCache(docId).catch(() => {});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestCachedUrl = getInMemoryDocumentPreviewUrl(cacheKey);
|
|
||||||
if (latestCachedUrl) return latestCachedUrl;
|
|
||||||
|
|
||||||
const contentType = row.contentType || 'image/jpeg';
|
|
||||||
const bytes = row.data;
|
|
||||||
if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) {
|
|
||||||
await removeDocumentPreviewCache(docId).catch(() => {});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(new Blob([bytes], { type: contentType }));
|
|
||||||
setInMemoryDocumentPreviewUrl(cacheKey, url);
|
|
||||||
return url;
|
|
||||||
})();
|
|
||||||
|
|
||||||
inFlightPersistedPreviewUrl.set(persistedKey, promise);
|
|
||||||
try {
|
|
||||||
return await promise;
|
|
||||||
} finally {
|
|
||||||
if (inFlightPersistedPreviewUrl.get(persistedKey) === promise) {
|
|
||||||
inFlightPersistedPreviewUrl.delete(persistedKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function primeDocumentPreviewCache(
|
export async function primeDocumentPreviewCache(
|
||||||
docId: string,
|
docId: string,
|
||||||
lastModified: number,
|
previewVersion: string | number,
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
options?: { signal?: AbortSignal },
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const primeKey = `${cacheKey}:${Number(lastModified)}`;
|
const memory = getInMemoryDocumentPreviewUrl(cacheKey);
|
||||||
const existingPrime = inFlightPreviewPrime.get(primeKey);
|
if (memory) return memory;
|
||||||
if (existingPrime) {
|
const primeKey = `${docId}:${previewVersion}`;
|
||||||
return existingPrime;
|
const existing = inFlightPreviewPrime.get(primeKey);
|
||||||
}
|
|
||||||
|
|
||||||
const promise = (async (): Promise<string | null> => {
|
|
||||||
const existing = await getPersistedDocumentPreviewUrl(docId, lastModified, cacheKey);
|
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
const promise = (async () => {
|
||||||
const fetchOptions = {
|
const response = await getCachedBlob(
|
||||||
signal: options?.signal,
|
previewBlobCacheKey(docId, previewVersion),
|
||||||
cache: 'no-store' as const,
|
() => fetchPreviewSource(docId, options?.signal),
|
||||||
};
|
).catch(() => null);
|
||||||
|
if (!response?.ok) return null;
|
||||||
// Prefer presign path for priming so healthy direct object access avoids proxy load.
|
const blob = await response.blob();
|
||||||
let res = await fetch(documentPreviewPresignUrl(docId), fetchOptions).catch(() => null);
|
if (blob.size === 0) return null;
|
||||||
if (!res || !res.ok) {
|
const url = URL.createObjectURL(blob);
|
||||||
res = await fetch(documentPreviewFallbackUrl(docId), fetchOptions).catch(() => null);
|
|
||||||
}
|
|
||||||
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);
|
setInMemoryDocumentPreviewUrl(cacheKey, url);
|
||||||
return url;
|
return url;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
inFlightPreviewPrime.set(primeKey, promise);
|
inFlightPreviewPrime.set(primeKey, promise);
|
||||||
try {
|
try {
|
||||||
return await promise;
|
return await promise;
|
||||||
} finally {
|
} finally {
|
||||||
if (inFlightPreviewPrime.get(primeKey) === promise) {
|
|
||||||
inFlightPreviewPrime.delete(primeKey);
|
inFlightPreviewPrime.delete(primeKey);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearAllDocumentPreviewCaches(): Promise<void> {
|
export async function clearAllDocumentPreviewCaches(): Promise<void> {
|
||||||
clearInMemoryDocumentPreviewCache();
|
clearInMemoryDocumentPreviewCache();
|
||||||
await clearPersistedDocumentPreviewCache();
|
await clearBlobCache();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
22
src/lib/client/epub/location-controller.ts
Normal file
22
src/lib/client/epub/location-controller.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
export type EpubLocation = string | number;
|
||||||
|
|
||||||
|
export function isDirectionalEpubLocation(location: EpubLocation): location is 'next' | 'prev' {
|
||||||
|
return location === 'next' || location === 'prev';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldNavigateToDifferentCfi(
|
||||||
|
location: EpubLocation,
|
||||||
|
currentStartCfi: string | undefined,
|
||||||
|
): location is string {
|
||||||
|
return typeof location === 'string'
|
||||||
|
&& !isDirectionalEpubLocation(location)
|
||||||
|
&& !!currentStartCfi
|
||||||
|
&& location !== currentStartCfi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldPersistEpubLocation(
|
||||||
|
documentId: string | undefined,
|
||||||
|
previousLocation: EpubLocation,
|
||||||
|
): documentId is string {
|
||||||
|
return typeof documentId === 'string' && documentId.length > 0 && previousLocation !== 1;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
export type OnboardingStep = 'privacy' | 'claim' | 'migration' | 'changelog' | 'done';
|
export type OnboardingStep = 'privacy' | 'claim' | 'changelog' | 'done';
|
||||||
|
|
||||||
export type OnboardingStepSnapshot = {
|
export type OnboardingStepSnapshot = {
|
||||||
privacyRequired: boolean;
|
privacyRequired: boolean;
|
||||||
privacyAccepted: boolean;
|
privacyAccepted: boolean;
|
||||||
claimEligible: boolean;
|
claimEligible: boolean;
|
||||||
claimHasData: boolean;
|
claimHasData: boolean;
|
||||||
migrationRequired: boolean;
|
|
||||||
changelogPending: boolean;
|
changelogPending: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -18,10 +17,6 @@ export function resolveNextOnboardingStep(snapshot: OnboardingStepSnapshot): Onb
|
||||||
return 'claim';
|
return 'claim';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.migrationRequired) {
|
|
||||||
return 'migration';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.changelogPending) {
|
if (snapshot.changelogPending) {
|
||||||
return 'changelog';
|
return 'changelog';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
src/lib/client/query-keys.ts
Normal file
15
src/lib/client/query-keys.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export const queryKeys = {
|
||||||
|
documents: (sessionId: string) => ['documents', sessionId] as const,
|
||||||
|
document: (sessionId: string, documentId: string) => ['documents', sessionId, documentId] as const,
|
||||||
|
preferences: (sessionId: string) => ['preferences', sessionId] as const,
|
||||||
|
progress: (sessionId: string, documentId: string) => ['progress', sessionId, documentId] as const,
|
||||||
|
documentSettings: (sessionId: string, documentId: string) => ['document-settings', sessionId, documentId] as const,
|
||||||
|
onboarding: (sessionId: string) => ['onboarding', sessionId] as const,
|
||||||
|
folders: (sessionId: string) => ['folders', sessionId] as const,
|
||||||
|
audiobook: (sessionId: string, bookId: string) => ['audiobook', sessionId, bookId] as const,
|
||||||
|
ttsManifest: (sessionId: string, documentId: string) => ['tts-manifest', sessionId, documentId] as const,
|
||||||
|
parsedDocument: (sessionId: string, documentId: string) => ['parsed-document', sessionId, documentId] as const,
|
||||||
|
claimCounts: (sessionId: string) => ['claim-counts', sessionId] as const,
|
||||||
|
rateLimit: (sessionId: string) => ['rate-limit', sessionId] as const,
|
||||||
|
admin: (scope: string) => ['admin', scope] as const,
|
||||||
|
};
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
export interface ResolvedTtsCredentials {
|
export interface ResolvedTtsCredentials {
|
||||||
/** Provider id passed downstream to TTS generation (one of the 4 built-in IDs). */
|
/** Provider id passed downstream to TTS generation (one of the 4 built-in IDs). */
|
||||||
provider: string;
|
provider: string;
|
||||||
/** API key for the request. Empty string when neither admin nor user supplied one. */
|
/** Decrypted API key from the selected admin-managed provider. */
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
/** Base URL, or undefined to fall through to provider defaults. */
|
/** Base URL, or undefined to fall through to provider defaults. */
|
||||||
baseUrl: string | undefined;
|
baseUrl: string | undefined;
|
||||||
|
|
@ -21,62 +21,26 @@ export interface ResolvedTtsCredentials {
|
||||||
/**
|
/**
|
||||||
* Resolve TTS credentials for an incoming request.
|
* Resolve TTS credentials for an incoming request.
|
||||||
*
|
*
|
||||||
* 1. If `restrictUserApiKeys` is enabled, only admin shared providers are
|
* Only admin-managed shared providers can supply credentials. Built-in
|
||||||
* used. Built-in provider ids and user-supplied key/base headers are
|
* provider ids select the preferred enabled shared provider of that type.
|
||||||
* ignored.
|
|
||||||
* 2. If `providerHeader` matches an enabled admin provider slug, use its
|
|
||||||
* server-stored credentials. Any `x-openai-key` / `x-openai-base-url`
|
|
||||||
* headers from the client are ignored — admin keys must never round-trip
|
|
||||||
* through the client.
|
|
||||||
* 3. Otherwise, treat `providerHeader` as a built-in provider id and use the
|
|
||||||
* per-user `x-openai-key` / `x-openai-base-url` headers as today.
|
|
||||||
*
|
*
|
||||||
* Returns `null` when the request references a slug that exists but is
|
* Returns `null` when the request references a slug that exists but is
|
||||||
* disabled — callers should reject with a 4xx.
|
* disabled — callers should reject with a 4xx.
|
||||||
*/
|
*/
|
||||||
export async function resolveTtsCredentials(opts: {
|
export async function resolveTtsCredentials(opts: {
|
||||||
providerHeader: string | null;
|
providerHeader: string | null;
|
||||||
apiKeyHeader: string | null;
|
|
||||||
baseUrlHeader: string | null;
|
|
||||||
fallbackProvider?: string;
|
fallbackProvider?: string;
|
||||||
restrictUserApiKeys?: boolean;
|
|
||||||
}): Promise<ResolvedTtsCredentials | { error: 'provider_disabled' | 'provider_unknown' | 'no_shared_provider_configured'; slug: string }> {
|
}): Promise<ResolvedTtsCredentials | { error: 'provider_disabled' | 'provider_unknown' | 'no_shared_provider_configured'; slug: string }> {
|
||||||
const requestedProvider = opts.providerHeader || opts.fallbackProvider || 'openai';
|
const requestedProvider = opts.providerHeader || opts.fallbackProvider || 'openai';
|
||||||
|
|
||||||
if (opts.restrictUserApiKeys) {
|
const admin = isBuiltInProviderId(requestedProvider)
|
||||||
const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider);
|
? await resolvePreferredEnabledAdminProvider({
|
||||||
const fallback = opts.fallbackProvider || '';
|
requestedSlug: null,
|
||||||
const selected = await resolvePreferredEnabledAdminProvider({
|
runtimeDefaultSlug: opts.fallbackProvider || '',
|
||||||
requestedSlug: requestedIsBuiltIn ? null : requestedProvider,
|
})
|
||||||
runtimeDefaultSlug: fallback,
|
: await getEnabledAdminProviderBySlug(requestedProvider);
|
||||||
});
|
|
||||||
if (!selected) {
|
|
||||||
return { error: 'no_shared_provider_configured', slug: requestedProvider };
|
|
||||||
}
|
|
||||||
const apiKey = await decryptedKeyFor(selected);
|
|
||||||
return {
|
|
||||||
provider: selected.providerType,
|
|
||||||
apiKey,
|
|
||||||
baseUrl: selected.baseUrl || undefined,
|
|
||||||
fromAdmin: true,
|
|
||||||
adminRecord: selected,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Built-in provider ids are not admin slugs — short-circuit.
|
|
||||||
if (isBuiltInProviderId(requestedProvider)) {
|
|
||||||
return {
|
|
||||||
provider: requestedProvider,
|
|
||||||
apiKey: opts.apiKeyHeader || '',
|
|
||||||
baseUrl: opts.baseUrlHeader || undefined,
|
|
||||||
fromAdmin: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not a built-in id → try to look it up as an admin slug.
|
|
||||||
const admin = await getEnabledAdminProviderBySlug(requestedProvider);
|
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
return { error: 'provider_unknown', slug: requestedProvider };
|
return { error: 'no_shared_provider_configured', slug: requestedProvider };
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = await decryptedKeyFor(admin);
|
const apiKey = await decryptedKeyFor(admin);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
ttsSegmentVariants,
|
ttsSegmentVariants,
|
||||||
userPreferences,
|
userPreferences,
|
||||||
userDocumentProgress,
|
userDocumentProgress,
|
||||||
|
userFolders,
|
||||||
|
userOnboarding,
|
||||||
} from '@openreader/database/schema';
|
} from '@openreader/database/schema';
|
||||||
import { eq, and, inArray } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy';
|
import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy';
|
||||||
|
|
@ -115,7 +117,7 @@ export async function claimAnonymousData(
|
||||||
options?: { cleanupLegacySources?: boolean },
|
options?: { cleanupLegacySources?: boolean },
|
||||||
) {
|
) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0 };
|
return { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0, folders: 0, onboarding: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([
|
const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([
|
||||||
|
|
@ -129,13 +131,16 @@ export async function claimAnonymousData(
|
||||||
.where(eq(audiobooks.userId, unclaimedUserId)) as Promise<Array<{ id: string }>>,
|
.where(eq(audiobooks.userId, unclaimedUserId)) as Promise<Array<{ id: string }>>,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed] = await Promise.all([
|
const foldersClaimed = await transferUserFolders(unclaimedUserId, userId);
|
||||||
|
const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed, onboardingClaimed] = await Promise.all([
|
||||||
transferUserDocuments(unclaimedUserId, userId, { namespace, transferTts: true }),
|
transferUserDocuments(unclaimedUserId, userId, { namespace, transferTts: true }),
|
||||||
transferUserAudiobooks(unclaimedUserId, userId, namespace),
|
transferUserAudiobooks(unclaimedUserId, userId, namespace),
|
||||||
transferUserPreferences(unclaimedUserId, userId),
|
transferUserPreferences(unclaimedUserId, userId),
|
||||||
transferUserProgress(unclaimedUserId, userId),
|
transferUserProgress(unclaimedUserId, userId),
|
||||||
transferUserDocumentSettings(unclaimedUserId, userId),
|
transferUserDocumentSettings(unclaimedUserId, userId),
|
||||||
|
transferUserOnboarding(unclaimedUserId, userId),
|
||||||
]);
|
]);
|
||||||
|
await db.delete(userFolders).where(eq(userFolders.userId, unclaimedUserId));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
options?.cleanupLegacySources !== false
|
options?.cleanupLegacySources !== false
|
||||||
|
|
@ -168,9 +173,30 @@ export async function claimAnonymousData(
|
||||||
preferences: preferencesClaimed,
|
preferences: preferencesClaimed,
|
||||||
progress: progressClaimed,
|
progress: progressClaimed,
|
||||||
documentSettings: documentSettingsClaimed,
|
documentSettings: documentSettingsClaimed,
|
||||||
|
folders: foldersClaimed,
|
||||||
|
onboarding: onboardingClaimed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function transferUserFolders(fromUserId: string, toUserId: string): Promise<number> {
|
||||||
|
if (!fromUserId || !toUserId || fromUserId === toUserId) return 0;
|
||||||
|
const rows = await db.select().from(userFolders).where(eq(userFolders.userId, fromUserId));
|
||||||
|
for (const row of rows) {
|
||||||
|
await db.insert(userFolders).values({ ...row, userId: toUserId }).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferUserOnboarding(fromUserId: string, toUserId: string): Promise<number> {
|
||||||
|
if (!fromUserId || !toUserId || fromUserId === toUserId) return 0;
|
||||||
|
const rows = await db.select().from(userOnboarding).where(eq(userOnboarding.userId, fromUserId));
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return 0;
|
||||||
|
await db.insert(userOnboarding).values({ ...row, userId: toUserId }).onConflictDoNothing();
|
||||||
|
await db.delete(userOnboarding).where(eq(userOnboarding.userId, fromUserId));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transfer documents from one userId to another.
|
* Transfer documents from one userId to another.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ export type AppendUserExportArchiveInput = {
|
||||||
exportedAtMs: number;
|
exportedAtMs: number;
|
||||||
profileData: unknown;
|
profileData: unknown;
|
||||||
preferences: unknown | null;
|
preferences: unknown | null;
|
||||||
|
folders?: unknown[];
|
||||||
|
onboarding?: unknown | null;
|
||||||
readingHistory: unknown[];
|
readingHistory: unknown[];
|
||||||
ttsUsage: unknown[];
|
ttsUsage: unknown[];
|
||||||
jobEvents: unknown[];
|
jobEvents: unknown[];
|
||||||
|
|
@ -144,6 +146,8 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
|
||||||
exportedAtMs,
|
exportedAtMs,
|
||||||
profileData,
|
profileData,
|
||||||
preferences,
|
preferences,
|
||||||
|
folders = [],
|
||||||
|
onboarding = null,
|
||||||
readingHistory,
|
readingHistory,
|
||||||
ttsUsage,
|
ttsUsage,
|
||||||
jobEvents,
|
jobEvents,
|
||||||
|
|
@ -171,6 +175,8 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
|
||||||
if (preferences) {
|
if (preferences) {
|
||||||
appendJson(archive, 'preferences.json', preferences);
|
appendJson(archive, 'preferences.json', preferences);
|
||||||
}
|
}
|
||||||
|
appendJson(archive, 'folders.json', folders);
|
||||||
|
if (onboarding) appendJson(archive, 'onboarding.json', onboarding);
|
||||||
appendJson(archive, 'reading_history.json', readingHistory);
|
appendJson(archive, 'reading_history.json', readingHistory);
|
||||||
appendJson(archive, 'tts_usage.json', ttsUsage);
|
appendJson(archive, 'tts_usage.json', ttsUsage);
|
||||||
appendJson(archive, 'job_events.json', jobEvents);
|
appendJson(archive, 'job_events.json', jobEvents);
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,11 @@ export function sanitizePreferencesPatch(
|
||||||
case 'savedVoices':
|
case 'savedVoices':
|
||||||
out[key] = sanitizeSavedVoices(value);
|
out[key] = sanitizeSavedVoices(value);
|
||||||
break;
|
break;
|
||||||
|
case 'documentListState':
|
||||||
|
if (isRecord(value)) {
|
||||||
|
out[key] = { ...value, folders: [] } as unknown as SyncedPreferencesPatch[typeof key];
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { normalizeVersion } from '@/lib/shared/changelog';
|
|
||||||
|
|
||||||
export const USER_PREFERENCES_META_KEY = '_meta';
|
|
||||||
export const USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY = 'lastSeenAppVersion';
|
|
||||||
|
|
||||||
export type UserPreferencesMeta = Record<string, unknown> & {
|
|
||||||
lastSeenAppVersion?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deserializeUserPreferencesPayload(value: unknown): Record<string, unknown> {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return isRecord(parsed) ? parsed : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isRecord(value) ? value : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractUserPreferencesMeta(payload: unknown): UserPreferencesMeta {
|
|
||||||
const record = deserializeUserPreferencesPayload(payload);
|
|
||||||
const rawMeta = record[USER_PREFERENCES_META_KEY];
|
|
||||||
if (!isRecord(rawMeta)) return {};
|
|
||||||
|
|
||||||
const out: UserPreferencesMeta = { ...rawMeta };
|
|
||||||
const rawLastSeen = out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY];
|
|
||||||
if (typeof rawLastSeen === 'string') {
|
|
||||||
out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY] = normalizeVersion(rawLastSeen);
|
|
||||||
} else {
|
|
||||||
delete out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY];
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withUserPreferencesMeta(
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
meta: UserPreferencesMeta,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const out = { ...payload };
|
|
||||||
delete out[USER_PREFERENCES_META_KEY];
|
|
||||||
if (Object.keys(meta).length > 0) {
|
|
||||||
out[USER_PREFERENCES_META_KEY] = meta;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
export type OnboardingStorageScope = 'local-dexie' | 'server-user-preferences' | 'hybrid';
|
|
||||||
|
|
||||||
export type OnboardingStateDescriptor = {
|
|
||||||
/**
|
|
||||||
* Where this state is persisted today.
|
|
||||||
* - local-dexie: browser/device-local only
|
|
||||||
* - server-user-preferences: per-user on server
|
|
||||||
* - hybrid: intentionally persisted in both places
|
|
||||||
*/
|
|
||||||
scope: OnboardingStorageScope;
|
|
||||||
/**
|
|
||||||
* Optional local key name when state is stored in Dexie app-config.
|
|
||||||
*/
|
|
||||||
localKey?: string;
|
|
||||||
/**
|
|
||||||
* Optional server-side key when state is stored in user preferences/meta.
|
|
||||||
*/
|
|
||||||
serverKey?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Central registry for onboarding-related state and where each item lives.
|
|
||||||
* Keep this map up to date whenever onboarding persistence changes.
|
|
||||||
*/
|
|
||||||
export const ONBOARDING_STATE_REGISTRY = {
|
|
||||||
privacyAccepted: {
|
|
||||||
scope: 'local-dexie',
|
|
||||||
localKey: 'privacyAccepted',
|
|
||||||
},
|
|
||||||
firstVisitSettingsOpened: {
|
|
||||||
scope: 'local-dexie',
|
|
||||||
localKey: 'firstVisit',
|
|
||||||
},
|
|
||||||
documentsMigrationPrompted: {
|
|
||||||
scope: 'local-dexie',
|
|
||||||
localKey: 'documentsMigrationPrompted',
|
|
||||||
},
|
|
||||||
changelogLastSeenAppVersion: {
|
|
||||||
scope: 'server-user-preferences',
|
|
||||||
serverKey: '_meta.lastSeenAppVersion',
|
|
||||||
},
|
|
||||||
} as const satisfies Record<string, OnboardingStateDescriptor>;
|
|
||||||
|
|
||||||
export type OnboardingStateKey = keyof typeof ONBOARDING_STATE_REGISTRY;
|
|
||||||
|
|
||||||
|
|
@ -29,8 +29,6 @@ export function clampTtsSegmentMaxBlockLength(value: number | undefined | null):
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfigValues {
|
export interface AppConfigValues {
|
||||||
apiKey: string;
|
|
||||||
baseUrl: string;
|
|
||||||
viewType: ViewType;
|
viewType: ViewType;
|
||||||
voiceSpeed: number;
|
voiceSpeed: number;
|
||||||
audioPlayerSpeed: number;
|
audioPlayerSpeed: number;
|
||||||
|
|
@ -55,10 +53,7 @@ export interface AppConfigValues {
|
||||||
epubWordHighlightEnabled: boolean;
|
epubWordHighlightEnabled: boolean;
|
||||||
htmlHighlightEnabled: boolean;
|
htmlHighlightEnabled: boolean;
|
||||||
htmlWordHighlightEnabled: boolean;
|
htmlWordHighlightEnabled: boolean;
|
||||||
firstVisit: boolean;
|
|
||||||
documentListState: DocumentListState;
|
documentListState: DocumentListState;
|
||||||
privacyAccepted: boolean;
|
|
||||||
documentsMigrationPrompted: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -75,8 +70,6 @@ export function getAppConfigDefaults(): AppConfigValues {
|
||||||
// provider id (the old 'custom-openai') into every user's config, since that
|
// provider id (the old 'custom-openai') into every user's config, since that
|
||||||
// value isn't actually selectable in shared-provider mode.
|
// value isn't actually selectable in shared-provider mode.
|
||||||
return {
|
return {
|
||||||
apiKey: '',
|
|
||||||
baseUrl: '',
|
|
||||||
viewType: 'single',
|
viewType: 'single',
|
||||||
voiceSpeed: 1,
|
voiceSpeed: 1,
|
||||||
audioPlayerSpeed: 1,
|
audioPlayerSpeed: 1,
|
||||||
|
|
@ -101,7 +94,6 @@ export function getAppConfigDefaults(): AppConfigValues {
|
||||||
epubWordHighlightEnabled: true,
|
epubWordHighlightEnabled: true,
|
||||||
htmlHighlightEnabled: true,
|
htmlHighlightEnabled: true,
|
||||||
htmlWordHighlightEnabled: true,
|
htmlWordHighlightEnabled: true,
|
||||||
firstVisit: false,
|
|
||||||
documentListState: {
|
documentListState: {
|
||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
sortDirection: 'asc',
|
sortDirection: 'asc',
|
||||||
|
|
@ -110,8 +102,6 @@ export function getAppConfigDefaults(): AppConfigValues {
|
||||||
showHint: true,
|
showHint: true,
|
||||||
viewMode: 'grid',
|
viewMode: 'grid',
|
||||||
},
|
},
|
||||||
privacyAccepted: false,
|
|
||||||
documentsMigrationPrompted: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export interface BaseDocument {
|
||||||
size: number;
|
size: number;
|
||||||
lastModified: number;
|
lastModified: number;
|
||||||
recentlyOpenedAt?: number;
|
recentlyOpenedAt?: number;
|
||||||
|
contentVersion?: string;
|
||||||
type: DocumentType;
|
type: DocumentType;
|
||||||
scope?: 'user';
|
scope?: 'user';
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export const SYNCED_PREFERENCE_KEYS = [
|
||||||
'epubWordHighlightEnabled',
|
'epubWordHighlightEnabled',
|
||||||
'htmlHighlightEnabled',
|
'htmlHighlightEnabled',
|
||||||
'htmlWordHighlightEnabled',
|
'htmlWordHighlightEnabled',
|
||||||
|
'documentListState',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];
|
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ test.describe('Document folders and hint persistence', () => {
|
||||||
// Hint should disappear
|
// Hint should disappear
|
||||||
await expect(hint).toHaveCount(0);
|
await expect(hint).toHaveCount(0);
|
||||||
|
|
||||||
// Ensure the dismissal has been persisted to IndexedDB before reloading
|
// Ensure the dismissal has been persisted to server preferences before reloading
|
||||||
await waitForDocumentListHintPersist(page, false);
|
await waitForDocumentListHintPersist(page, false);
|
||||||
|
|
||||||
// Reload and ensure it remains dismissed
|
// Reload and ensure it remains dismissed
|
||||||
|
|
|
||||||
|
|
@ -533,27 +533,14 @@ export async function triggerViewportResize(page: Page, width: number, height: n
|
||||||
await page.setViewportSize({ width, height });
|
await page.setViewportSize({ width, height });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for DocumentListState.showHint to persist in IndexedDB 'app-config' store
|
// Wait for DocumentListState.showHint to persist in server preferences.
|
||||||
export async function waitForDocumentListHintPersist(page: Page, expected: boolean) {
|
export async function waitForDocumentListHintPersist(page: Page, expected: boolean) {
|
||||||
await page.waitForFunction(async (exp) => {
|
await page.waitForFunction(async (exp) => {
|
||||||
try {
|
try {
|
||||||
const openDb = () => new Promise<IDBDatabase>((resolve, reject) => {
|
const response = await fetch('/api/user/state/preferences', { cache: 'no-store' });
|
||||||
const req = indexedDB.open('openreader-db');
|
if (!response.ok) return false;
|
||||||
req.onsuccess = () => resolve(req.result);
|
const item = await response.json() as { preferences?: { documentListState?: unknown } };
|
||||||
req.onerror = () => reject(req.error);
|
const state = item.preferences?.documentListState;
|
||||||
});
|
|
||||||
const db = await openDb();
|
|
||||||
const readConfig = () => new Promise<unknown>((resolve, reject) => {
|
|
||||||
const tx = db.transaction(['app-config'], 'readonly');
|
|
||||||
const store = tx.objectStore('app-config');
|
|
||||||
const getReq = store.get('singleton');
|
|
||||||
getReq.onsuccess = () => resolve(getReq.result);
|
|
||||||
getReq.onerror = () => reject(getReq.error);
|
|
||||||
});
|
|
||||||
const item = await readConfig();
|
|
||||||
db.close();
|
|
||||||
if (!item || typeof item !== 'object') return false;
|
|
||||||
const state = (item as { documentListState?: unknown }).documentListState;
|
|
||||||
if (!state || typeof state !== 'object') return false;
|
if (!state || typeof state !== 'object') return false;
|
||||||
const showHint = (state as { showHint?: unknown }).showHint;
|
const showHint = (state as { showHint?: unknown }).showHint;
|
||||||
return typeof showHint === 'boolean' && showHint === exp;
|
return typeof showHint === 'boolean' && showHint === exp;
|
||||||
|
|
|
||||||
74
tests/unit/blob-cache.vitest.spec.ts
Normal file
74
tests/unit/blob-cache.vitest.spec.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
audioBlobCacheKey,
|
||||||
|
documentBlobCacheKey,
|
||||||
|
getCachedBlob,
|
||||||
|
previewBlobCacheKey,
|
||||||
|
} from '../../src/lib/client/cache/blob-cache';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blob cache stable keys', () => {
|
||||||
|
test('builds versioned synthetic keys', () => {
|
||||||
|
expect(documentBlobCacheKey('doc/id', 'v1')).toBe('/openreader-cache/documents/doc%2Fid/v1');
|
||||||
|
expect(previewBlobCacheKey('doc', 'etag/1')).toBe('/openreader-cache/previews/doc/etag%2F1');
|
||||||
|
expect(audioBlobCacheKey('audio/key', 2)).toBe('/openreader-cache/audio/audio%2Fkey/2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a cache hit without fetching', async () => {
|
||||||
|
const cached = new Response('cached');
|
||||||
|
const match = vi.fn().mockResolvedValue(cached);
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
vi.stubGlobal('caches', { open: vi.fn().mockResolvedValue({ match, put: vi.fn() }) });
|
||||||
|
const fetchSource = vi.fn();
|
||||||
|
|
||||||
|
expect(await (await getCachedBlob('/openreader-cache/documents/doc/v1', fetchSource)).text()).toBe('cached');
|
||||||
|
expect(fetchSource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetches and caches a successful full response on a miss', async () => {
|
||||||
|
const put = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
vi.stubGlobal('caches', {
|
||||||
|
open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue(undefined), put }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await getCachedBlob('/openreader-cache/documents/doc/v2', async () => new Response('network'));
|
||||||
|
expect(await response.text()).toBe('network');
|
||||||
|
expect(put).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats missing Cache Storage and cache write failures as non-fatal', async () => {
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
vi.stubGlobal('caches', {
|
||||||
|
open: vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('unavailable'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
match: vi.fn().mockResolvedValue(undefined),
|
||||||
|
put: vi.fn().mockRejectedValue(new Error('quota')),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await (await getCachedBlob('/openreader-cache/documents/doc/v3', async () => new Response('first'))).text()).toBe('first');
|
||||||
|
expect(await (await getCachedBlob('/openreader-cache/documents/doc/v4', async () => new Response('second'))).text()).toBe('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not cache partial responses and throws for failed fetches', async () => {
|
||||||
|
const put = vi.fn();
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
vi.stubGlobal('caches', {
|
||||||
|
open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue(undefined), put }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const partial = await getCachedBlob(
|
||||||
|
'/openreader-cache/audio/key/v1',
|
||||||
|
async () => new Response('partial', { status: 206, headers: { 'Content-Range': 'bytes 0-6/20' } }),
|
||||||
|
);
|
||||||
|
expect(partial.status).toBe(206);
|
||||||
|
expect(put).not.toHaveBeenCalled();
|
||||||
|
await expect(getCachedBlob('/openreader-cache/audio/key/v2', async () => new Response(null, { status: 404 })))
|
||||||
|
.rejects.toThrow('Blob fetch failed: 404');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '../../src/types/documents';
|
|
||||||
import { ensureCachedDocumentCore } from '../../src/lib/client/cache/documents';
|
|
||||||
import { makeBaseDocument } from './support/document-fixtures';
|
|
||||||
|
|
||||||
describe('document-cache-core', () => {
|
|
||||||
test('returns cached PDF without downloading', async () => {
|
|
||||||
const meta: BaseDocument = makeBaseDocument({
|
|
||||||
id: 'pdf1',
|
|
||||||
name: 'a.pdf',
|
|
||||||
size: 10,
|
|
||||||
lastModified: 1_700_000_000_001,
|
|
||||||
type: 'pdf',
|
|
||||||
});
|
|
||||||
|
|
||||||
let downloads = 0;
|
|
||||||
const cached: PDFDocument = { ...meta, type: 'pdf', data: new ArrayBuffer(1) };
|
|
||||||
|
|
||||||
const result = await ensureCachedDocumentCore(meta, {
|
|
||||||
get: async () => cached,
|
|
||||||
putPdf: async () => { throw new Error('should not put'); },
|
|
||||||
putEpub: async () => { throw new Error('should not put'); },
|
|
||||||
putHtml: async () => { throw new Error('should not put'); },
|
|
||||||
download: async () => {
|
|
||||||
downloads++;
|
|
||||||
return new ArrayBuffer(0);
|
|
||||||
},
|
|
||||||
decodeText: () => '',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(downloads).toBe(0);
|
|
||||||
expect(result.type).toBe('pdf');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('downloads and stores on cache miss (EPUB)', async () => {
|
|
||||||
const meta: BaseDocument = makeBaseDocument({
|
|
||||||
id: 'epub1',
|
|
||||||
name: 'b.epub',
|
|
||||||
size: 10,
|
|
||||||
lastModified: 1_700_000_000_002,
|
|
||||||
type: 'epub',
|
|
||||||
});
|
|
||||||
|
|
||||||
const store = new Map<string, PDFDocument | EPUBDocument | HTMLDocument>();
|
|
||||||
let downloads = 0;
|
|
||||||
|
|
||||||
const result = await ensureCachedDocumentCore(meta, {
|
|
||||||
get: async (m) => store.get(m.id) ?? null,
|
|
||||||
putPdf: async () => { /* unused */ },
|
|
||||||
putEpub: async (m, data) => {
|
|
||||||
store.set(m.id, { ...m, type: 'epub', data } as EPUBDocument);
|
|
||||||
},
|
|
||||||
putHtml: async () => { /* unused */ },
|
|
||||||
download: async () => {
|
|
||||||
downloads++;
|
|
||||||
return new Uint8Array([1, 2, 3]).buffer;
|
|
||||||
},
|
|
||||||
decodeText: () => '',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(downloads).toBe(1);
|
|
||||||
expect(result.type).toBe('epub');
|
|
||||||
expect((result as EPUBDocument).data.byteLength).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('downloads, decodes, and stores HTML on cache miss', async () => {
|
|
||||||
const meta: BaseDocument = makeBaseDocument({
|
|
||||||
id: 'html1',
|
|
||||||
name: 'c.txt',
|
|
||||||
size: 5,
|
|
||||||
lastModified: 1_700_000_000_003,
|
|
||||||
type: 'html',
|
|
||||||
});
|
|
||||||
|
|
||||||
const store = new Map<string, PDFDocument | EPUBDocument | HTMLDocument>();
|
|
||||||
let decodedCalls = 0;
|
|
||||||
|
|
||||||
const result = await ensureCachedDocumentCore(meta, {
|
|
||||||
get: async (m) => store.get(m.id) ?? null,
|
|
||||||
putPdf: async () => { /* unused */ },
|
|
||||||
putEpub: async () => { /* unused */ },
|
|
||||||
putHtml: async (m, data) => {
|
|
||||||
store.set(m.id, { ...m, type: 'html', data } as HTMLDocument);
|
|
||||||
},
|
|
||||||
download: async () => new TextEncoder().encode('hello').buffer,
|
|
||||||
decodeText: (buf) => {
|
|
||||||
decodedCalls++;
|
|
||||||
return new TextDecoder().decode(new Uint8Array(buf));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(decodedCalls).toBe(1);
|
|
||||||
expect(result.type).toBe('html');
|
|
||||||
expect((result as HTMLDocument).data).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws deterministic cache error when download succeeds but backend misses persisted PDF', async () => {
|
|
||||||
const meta = makeBaseDocument({
|
|
||||||
id: 'pdf-cache-miss',
|
|
||||||
type: 'pdf',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
ensureCachedDocumentCore(meta, {
|
|
||||||
get: async () => null,
|
|
||||||
putPdf: async () => { /* simulate write path without persisted row */ },
|
|
||||||
putEpub: async () => { /* unused */ },
|
|
||||||
putHtml: async () => { /* unused */ },
|
|
||||||
download: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
||||||
decodeText: () => '',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('Failed to cache PDF');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
isDirectionalEpubLocation,
|
isDirectionalEpubLocation,
|
||||||
shouldNavigateToDifferentCfi,
|
shouldNavigateToDifferentCfi,
|
||||||
shouldPersistEpubLocation,
|
shouldPersistEpubLocation,
|
||||||
} from '../../src/hooks/epub/useEPUBLocationController';
|
} from '../../src/lib/client/epub/location-controller';
|
||||||
|
|
||||||
describe('EPUB location controller helpers', () => {
|
describe('EPUB location controller helpers', () => {
|
||||||
test('detects directional locations', () => {
|
test('detects directional locations', () => {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ describe('onboarding flow resolver', () => {
|
||||||
privacyAccepted: false,
|
privacyAccepted: false,
|
||||||
claimEligible: true,
|
claimEligible: true,
|
||||||
claimHasData: true,
|
claimHasData: true,
|
||||||
migrationRequired: true,
|
|
||||||
changelogPending: true,
|
changelogPending: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -22,33 +21,18 @@ describe('onboarding flow resolver', () => {
|
||||||
privacyAccepted: true,
|
privacyAccepted: true,
|
||||||
claimEligible: true,
|
claimEligible: true,
|
||||||
claimHasData: true,
|
claimHasData: true,
|
||||||
migrationRequired: true,
|
|
||||||
changelogPending: true,
|
changelogPending: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(step).toBe('claim');
|
expect(step).toBe('claim');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolves migration when no claim is needed', () => {
|
|
||||||
const step = resolveNextOnboardingStep({
|
|
||||||
privacyRequired: true,
|
|
||||||
privacyAccepted: true,
|
|
||||||
claimEligible: true,
|
|
||||||
claimHasData: false,
|
|
||||||
migrationRequired: true,
|
|
||||||
changelogPending: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(step).toBe('migration');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolves changelog when prior steps are clear', () => {
|
test('resolves changelog when prior steps are clear', () => {
|
||||||
const step = resolveNextOnboardingStep({
|
const step = resolveNextOnboardingStep({
|
||||||
privacyRequired: true,
|
privacyRequired: true,
|
||||||
privacyAccepted: true,
|
privacyAccepted: true,
|
||||||
claimEligible: true,
|
claimEligible: true,
|
||||||
claimHasData: false,
|
claimHasData: false,
|
||||||
migrationRequired: false,
|
|
||||||
changelogPending: true,
|
changelogPending: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,7 +45,6 @@ describe('onboarding flow resolver', () => {
|
||||||
privacyAccepted: true,
|
privacyAccepted: true,
|
||||||
claimEligible: true,
|
claimEligible: true,
|
||||||
claimHasData: false,
|
claimHasData: false,
|
||||||
migrationRequired: false,
|
|
||||||
changelogPending: false,
|
changelogPending: false,
|
||||||
});
|
});
|
||||||
const minimalStep = resolveNextOnboardingStep({
|
const minimalStep = resolveNextOnboardingStep({
|
||||||
|
|
@ -69,7 +52,6 @@ describe('onboarding flow resolver', () => {
|
||||||
privacyAccepted: false,
|
privacyAccepted: false,
|
||||||
claimEligible: false,
|
claimEligible: false,
|
||||||
claimHasData: false,
|
claimHasData: false,
|
||||||
migrationRequired: false,
|
|
||||||
changelogPending: false,
|
changelogPending: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
import { ONBOARDING_STATE_REGISTRY } from '../../src/lib/shared/onboarding-state';
|
|
||||||
import { SYNCED_PREFERENCE_KEYS } from '../../src/types/user-state';
|
|
||||||
|
|
||||||
describe('onboarding state storage scopes', () => {
|
|
||||||
test('keeps local onboarding flags out of synced server preferences', () => {
|
|
||||||
expect(ONBOARDING_STATE_REGISTRY.privacyAccepted.scope).toBe('local-dexie');
|
|
||||||
expect(ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.scope).toBe('local-dexie');
|
|
||||||
expect(ONBOARDING_STATE_REGISTRY.documentsMigrationPrompted.scope).toBe('local-dexie');
|
|
||||||
expect(ONBOARDING_STATE_REGISTRY.changelogLastSeenAppVersion.scope).toBe('server-user-preferences');
|
|
||||||
|
|
||||||
const synced = new Set<string>(SYNCED_PREFERENCE_KEYS);
|
|
||||||
|
|
||||||
expect(synced.has(ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey!)).toBe(false);
|
|
||||||
expect(synced.has(ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey!)).toBe(false);
|
|
||||||
expect(synced.has(ONBOARDING_STATE_REGISTRY.documentsMigrationPrompted.localKey!)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
19
tests/unit/query-keys.vitest.spec.ts
Normal file
19
tests/unit/query-keys.vitest.spec.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { queryKeys } from '../../src/lib/client/query-keys';
|
||||||
|
|
||||||
|
describe('query keys', () => {
|
||||||
|
test('isolates server state by session and document', () => {
|
||||||
|
expect(queryKeys.documents('user-a')).not.toEqual(queryKeys.documents('user-b'));
|
||||||
|
expect(queryKeys.progress('user-a', 'doc-a')).not.toEqual(queryKeys.progress('user-a', 'doc-b'));
|
||||||
|
expect(queryKeys.documentSettings('user-a', 'doc-a')).not.toEqual(queryKeys.documentSettings('user-b', 'doc-a'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defines centralized keys for migrated server-state domains', () => {
|
||||||
|
expect(queryKeys.preferences('user')).toEqual(['preferences', 'user']);
|
||||||
|
expect(queryKeys.onboarding('user')).toEqual(['onboarding', 'user']);
|
||||||
|
expect(queryKeys.folders('user')).toEqual(['folders', 'user']);
|
||||||
|
expect(queryKeys.audiobook('user', 'book')).toEqual(['audiobook', 'user', 'book']);
|
||||||
|
expect(queryKeys.ttsManifest('user', 'doc')).toEqual(['tts-manifest', 'user', 'doc']);
|
||||||
|
expect(queryKeys.parsedDocument('user', 'doc')).toEqual(['parsed-document', 'user', 'doc']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -25,6 +25,8 @@ describe('transferUserDocuments', () => {
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
last_modified INTEGER NOT NULL,
|
last_modified INTEGER NOT NULL,
|
||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
|
folder_id TEXT,
|
||||||
|
recently_opened_at INTEGER,
|
||||||
parse_state TEXT,
|
parse_state TEXT,
|
||||||
parsed_json_key TEXT,
|
parsed_json_key TEXT,
|
||||||
created_at INTEGER,
|
created_at INTEGER,
|
||||||
|
|
@ -149,6 +151,8 @@ describe('transferUserDocuments', () => {
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
last_modified INTEGER NOT NULL,
|
last_modified INTEGER NOT NULL,
|
||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
|
folder_id TEXT,
|
||||||
|
recently_opened_at INTEGER,
|
||||||
parse_state TEXT,
|
parse_state TEXT,
|
||||||
parsed_json_key TEXT,
|
parsed_json_key TEXT,
|
||||||
created_at INTEGER,
|
created_at INTEGER,
|
||||||
|
|
|
||||||
|
|
@ -533,7 +533,6 @@ describe('config helpers', () => {
|
||||||
test('builds synced preference patches and honors non-default filtering', () => {
|
test('builds synced preference patches and honors non-default filtering', () => {
|
||||||
expect(buildSyncedPreferencePatch({
|
expect(buildSyncedPreferencePatch({
|
||||||
voiceSpeed: 1.2,
|
voiceSpeed: 1.2,
|
||||||
baseUrl: 'http://localhost',
|
|
||||||
ttsModel: 'kokoro',
|
ttsModel: 'kokoro',
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
voiceSpeed: 1.2,
|
voiceSpeed: 1.2,
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
cancelPendingPreferenceSync,
|
|
||||||
flushUserPreferencesSync,
|
|
||||||
scheduleUserPreferencesSync,
|
|
||||||
} from '../../src/lib/client/api/user-state';
|
|
||||||
|
|
||||||
type FetchMock = ReturnType<typeof vi.fn> & { calls: Array<{ url: string; init?: RequestInit }> };
|
|
||||||
|
|
||||||
function installFetchMock(responder: () => Response): FetchMock {
|
|
||||||
const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
mock.calls.push({ url: String(input), init });
|
|
||||||
return responder();
|
|
||||||
}) as unknown as FetchMock;
|
|
||||||
mock.calls = [];
|
|
||||||
global.fetch = mock as unknown as typeof fetch;
|
|
||||||
return mock;
|
|
||||||
}
|
|
||||||
|
|
||||||
function okResponse(): Response {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ preferences: {}, clientUpdatedAtMs: Date.now(), applied: true }),
|
|
||||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('flushUserPreferencesSync', () => {
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
cancelPendingPreferenceSync();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cancelPendingPreferenceSync();
|
|
||||||
vi.useRealTimers();
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sends a pending debounced patch immediately instead of waiting for the timer', async () => {
|
|
||||||
const fetchMock = installFetchMock(okResponse);
|
|
||||||
|
|
||||||
scheduleUserPreferencesSync({ providerRef: 'shared-b', providerType: 'openai' }, 'user-1');
|
|
||||||
|
|
||||||
// Nothing should have fired yet — it's still debounced.
|
|
||||||
expect(fetchMock.calls).toHaveLength(0);
|
|
||||||
|
|
||||||
await flushUserPreferencesSync();
|
|
||||||
|
|
||||||
expect(fetchMock.calls).toHaveLength(1);
|
|
||||||
const { url, init } = fetchMock.calls[0];
|
|
||||||
expect(url).toContain('/api/user/state/preferences');
|
|
||||||
expect(init?.method).toBe('PUT');
|
|
||||||
const body = JSON.parse(String(init?.body));
|
|
||||||
expect(body.patch.providerRef).toBe('shared-b');
|
|
||||||
expect(body.patch.providerType).toBe('openai');
|
|
||||||
|
|
||||||
// The debounce timer must not also fire a duplicate request afterwards.
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
expect(fetchMock.calls).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is a no-op when there is no pending patch', async () => {
|
|
||||||
const fetchMock = installFetchMock(okResponse);
|
|
||||||
await flushUserPreferencesSync();
|
|
||||||
expect(fetchMock.calls).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects when the server write fails so callers can surface the error', async () => {
|
|
||||||
installFetchMock(() => new Response(JSON.stringify({ error: 'boom' }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}));
|
|
||||||
|
|
||||||
scheduleUserPreferencesSync({ providerRef: 'shared-b' }, 'user-1');
|
|
||||||
|
|
||||||
await expect(flushUserPreferencesSync()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers';
|
import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers';
|
||||||
|
|
||||||
interface HtmlDocumentRow {
|
|
||||||
id?: string;
|
|
||||||
data?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type HashCheckResult =
|
type HashCheckResult =
|
||||||
| { ok: true; storedId: string; computedId: string }
|
| { ok: true; storedId: string; computedId: string }
|
||||||
| { ok: false; reason: 'Missing stored html document' | 'Hash mismatch'; storedId?: string; computedId?: string };
|
| { ok: false; reason: 'Missing stored html document' | 'Hash mismatch' | 'Content fetch failed'; storedId?: string; computedId?: string };
|
||||||
|
|
||||||
test.describe('Document Upload Tests', () => {
|
test.describe('Document Upload Tests', () => {
|
||||||
test.beforeEach(async ({ page }, testInfo) => {
|
test.beforeEach(async ({ page }, testInfo) => {
|
||||||
|
|
@ -58,38 +53,24 @@ test.describe('Document Upload Tests', () => {
|
||||||
await expectDocumentListed(page, 'sample.txt');
|
await expectDocumentListed(page, 'sample.txt');
|
||||||
|
|
||||||
const result = await page.evaluate<HashCheckResult>(async () => {
|
const result = await page.evaluate<HashCheckResult>(async () => {
|
||||||
const idb = await new Promise<IDBDatabase>((resolve, reject) => {
|
const listRes = await fetch('/api/documents', { cache: 'no-store' });
|
||||||
const request = indexedDB.open('openreader-db');
|
const docs = listRes.ok
|
||||||
request.onerror = () => reject(request.error);
|
? ((await listRes.json()) as { documents?: Array<{ id: string; name: string }> }).documents ?? []
|
||||||
request.onsuccess = () => resolve(request.result);
|
: [];
|
||||||
});
|
const doc = docs.find((item) => item.name === 'sample.txt');
|
||||||
|
if (!doc?.id) return { ok: false, reason: 'Missing stored html document' as const };
|
||||||
|
|
||||||
try {
|
const contentRes = await fetch(`/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`, { cache: 'no-store' });
|
||||||
const docs = await new Promise<HtmlDocumentRow[]>((resolve, reject) => {
|
if (!contentRes.ok) return { ok: false, reason: 'Content fetch failed' as const, storedId: doc.id };
|
||||||
const tx = idb.transaction('html-documents', 'readonly');
|
const digest = await crypto.subtle.digest('SHA-256', await contentRes.arrayBuffer());
|
||||||
const store = tx.objectStore('html-documents');
|
|
||||||
const request = store.getAll();
|
|
||||||
request.onerror = () => reject(request.error);
|
|
||||||
request.onsuccess = () => resolve(request.result as HtmlDocumentRow[]);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!docs[0]?.data || !docs[0]?.id) {
|
|
||||||
return { ok: false, reason: 'Missing stored html document' as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = new TextEncoder().encode(String(docs[0].data));
|
|
||||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
||||||
const computedId = Array.from(new Uint8Array(digest))
|
const computedId = Array.from(new Uint8Array(digest))
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
if (computedId === docs[0].id) {
|
if (computedId === doc.id) {
|
||||||
return { ok: true as const, storedId: docs[0].id as string, computedId };
|
return { ok: true as const, storedId: doc.id, computedId };
|
||||||
}
|
|
||||||
return { ok: false as const, reason: 'Hash mismatch', storedId: docs[0].id as string, computedId };
|
|
||||||
} finally {
|
|
||||||
idb.close();
|
|
||||||
}
|
}
|
||||||
|
return { ok: false as const, reason: 'Hash mismatch', storedId: doc.id, computedId };
|
||||||
});
|
});
|
||||||
|
|
||||||
const detail = result.ok
|
const detail = result.ok
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue