diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index f239d64..aa78797 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -41,7 +41,7 @@ When a non-admin user picks a shared provider in **Settings → TTS Provider**: - The API key / base URL fields are hidden — those credentials never leave the server. - The TTS request still goes through the user's browser, but the server replaces the slug with the matching admin row's decrypted key and base URL before calling the upstream provider. -- The user's per-request `x-openai-key` / `x-openai-base-url` headers are ignored for shared slugs. +- TTS credentials are resolved only from admin-managed shared providers and are never accepted from client request headers. Whether users can supply their own personal built-in provider keys is controlled by the site feature `restrictUserApiKeys`: diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index f260e31..da6a7db 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -20,7 +20,7 @@ title: Stack - Interactions: `react-dnd`, `react-dropzone` - Server state: [TanStack Query](https://tanstack.com/query) (React Query v5) - Authentication: [Better Auth](https://www.better-auth.com/) client SDK -- Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB) +- Browser blob performance cache: Cache Storage (evictable; never authoritative) - Audio playback: [Howler.js](https://howlerjs.com/) - Notifications: `react-hot-toast` - Document rendering: diff --git a/eslint.config.mjs b/eslint.config.mjs index e7af21a..40e6794 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,7 +29,6 @@ const UI_ARCHITECTURE_FILES = [ "src/components/doclist/**/*.{ts,tsx}", "src/components/documents/DocumentUploader.tsx", "src/components/documents/DocumentSelectionModal.tsx", - "src/components/documents/DexieMigrationModal.tsx", "src/components/documents/ZoomControl.tsx", "src/components/player/**/*.{ts,tsx}", "src/components/reader/**/*.{ts,tsx}", diff --git a/package.json b/package.json index 2716aac..b084187 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,6 @@ "cmpstr": "^3.3.0", "compromise": "^14.15.1", "core-js": "^3.49.0", - "dexie": "^4.4.3", - "dexie-react-hooks": "^4.4.0", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "epubjs": "^0.3.93", @@ -102,8 +100,8 @@ "drizzle-kit": "^0.31.10", "eslint": "^9.39.4", "eslint-config-next": "^15.5.19", - "postcss": "^8.5.15", "openapi-typescript": "^7.10.1", + "postcss": "^8.5.15", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", "vitest": "^4.1.8" diff --git a/packages/compute-worker/tests/fixtures/fake-control-plane.ts b/packages/compute-worker/tests/fixtures/fake-control-plane.ts index 0cd1c86..1dc1b50 100644 --- a/packages/compute-worker/tests/fixtures/fake-control-plane.ts +++ b/packages/compute-worker/tests/fixtures/fake-control-plane.ts @@ -19,7 +19,7 @@ export class FakeControlPlane { private readonly artifactKeys = new Set(); private nextOpId = 1; - readonly deps: ComputeWorkerRouteDeps = { + readonly deps = { orchestrator: { enqueueOrReuse: async (request) => this.enqueueOrReuse(request), markRunning: async (input) => this.updateState(input.opId, { @@ -75,7 +75,7 @@ export class FakeControlPlane { }, }, artifactExists: async (key) => this.artifactKeys.has(key), - }; + } satisfies ComputeWorkerRouteDeps; seedState(state: ComputeState): void { this.stateByOpId.set(state.opId, state); diff --git a/packages/compute-worker/tests/unit/orphan-recovery.test.ts b/packages/compute-worker/tests/unit/orphan-recovery.test.ts index 54db314..7ec771d 100644 --- a/packages/compute-worker/tests/unit/orphan-recovery.test.ts +++ b/packages/compute-worker/tests/unit/orphan-recovery.test.ts @@ -24,8 +24,8 @@ describe('orphan recovery', () => { }); const firstSweep = await recoverOrphanedOperations({ - operationStateStore: fake.deps.operationStateStore!, - orchestrator: fake.deps.orchestrator!, + operationStateStore: fake.deps.operationStateStore, + orchestrator: fake.deps.orchestrator, whisperTimeoutMs: 30_000, pdfTimeoutMs: 300_000, opStaleMs: 1_800_000, @@ -38,8 +38,8 @@ describe('orphan recovery', () => { vi.advanceTimersByTime(31_000); const secondSweep = await recoverOrphanedOperations({ - operationStateStore: fake.deps.operationStateStore!, - orchestrator: fake.deps.orchestrator!, + operationStateStore: fake.deps.operationStateStore, + orchestrator: fake.deps.orchestrator, whisperTimeoutMs: 30_000, pdfTimeoutMs: 300_000, opStaleMs: 1_800_000, diff --git a/packages/database/migrations/postgres/0012_user_additions.sql b/packages/database/migrations/postgres/0012_user_additions.sql new file mode 100644 index 0000000..ec34226 --- /dev/null +++ b/packages/database/migrations/postgres/0012_user_additions.sql @@ -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"); \ No newline at end of file diff --git a/packages/database/migrations/postgres/meta/0012_snapshot.json b/packages/database/migrations/postgres/meta/0012_snapshot.json new file mode 100644 index 0000000..93bbd9c --- /dev/null +++ b/packages/database/migrations/postgres/meta/0012_snapshot.json @@ -0,0 +1,2263 @@ +{ + "id": "a79acd69-9095-4bf3-87e7-6acda32c7283", + "prevId": "6fa9be62-3d2b-43d2-8c49-11d9c6411a33", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_providers": { + "name": "admin_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_settings": { + "name": "admin_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobook_chapters": { + "name": "audiobook_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobooks": { + "name": "audiobooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_blob_leases": { + "name": "document_blob_leases", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_previews": { + "name": "document_previews", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_until_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "name": "document_previews_document_id_namespace_variant_pk", + "columns": [ + "document_id", + "namespace", + "variant" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_settings": { + "name": "document_settings", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "name": "document_settings_document_id_user_id_pk", + "columns": [ + "document_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recently_opened_at": { + "name": "recently_opened_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_modified", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_folder": { + "name": "idx_documents_user_id_folder", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_recently_opened": { + "name": "idx_documents_user_id_recently_opened", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recently_opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_folder_id_user_id_user_folders_id_user_id_fk": { + "name": "documents_folder_id_user_id_user_folders_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user_folders", + "columnsFrom": [ + "folder_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_tasks": { + "name": "scheduled_tasks", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_ms": { + "name": "interval_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_duration_ms": { + "name": "last_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_result_json": { + "name": "last_result_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_requested": { + "name": "run_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "running_since": { + "name": "running_since", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scheduled_tasks_interval_ms_positive": { + "name": "scheduled_tasks_interval_ms_positive", + "value": "\"scheduled_tasks\".\"interval_ms\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.tts_segment_entries": { + "name": "tts_segment_entries", + "schema": "", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_version": { + "name": "document_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_reader_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_char_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_href", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_location", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "name": "tts_segment_entries_segment_entry_id_user_id_pk", + "columns": [ + "segment_entry_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_variants": { + "name": "tts_segment_variants", + "schema": "", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "settings_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "name": "tts_segment_variants_segment_id_user_id_pk", + "columns": [ + "segment_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_document_progress": { + "name": "user_document_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "name": "user_document_progress_user_id_document_id_pk", + "columns": [ + "user_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_folders": { + "name": "user_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_folders_user_position": { + "name": "idx_user_folders_user_position", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_folders_user_id_user_id_fk": { + "name": "user_folders_user_id_user_id_fk", + "tableFrom": "user_folders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_folders_id_user_id_pk": { + "name": "user_folders_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_job_events": { + "name": "user_job_events", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "op_id": { + "name": "op_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_job_events_user_action_created": { + "name": "idx_user_job_events_user_action_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_job_events_user_id_user_id_fk": { + "name": "user_job_events_user_id_user_id_fk", + "tableFrom": "user_job_events", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_job_events_user_id_action_op_id_pk": { + "name": "user_job_events_user_id_action_op_id_pk", + "columns": [ + "user_id", + "action", + "op_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_onboarding": { + "name": "user_onboarding", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "privacy_accepted_at_ms": { + "name": "privacy_accepted_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_seen_app_version": { + "name": "last_seen_app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_onboarding_user_id_user_id_fk": { + "name": "user_onboarding_user_id_user_id_fk", + "tableFrom": "user_onboarding", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tts_chars": { + "name": "user_tts_chars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "char_count": { + "name": "char_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "name": "user_tts_chars_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/postgres/meta/_journal.json b/packages/database/migrations/postgres/meta/_journal.json index cfe7d84..dd955c7 100644 --- a/packages/database/migrations/postgres/meta/_journal.json +++ b/packages/database/migrations/postgres/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1780851107819, "tag": "0011_scheduled-tasks", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1781466220153, + "tag": "0012_user_additions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/migrations/sqlite/0012_user_additions.sql b/packages/database/migrations/sqlite/0012_user_additions.sql new file mode 100644 index 0000000..a93508b --- /dev/null +++ b/packages/database/migrations/sqlite/0012_user_additions.sql @@ -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`); \ No newline at end of file diff --git a/packages/database/migrations/sqlite/meta/0012_snapshot.json b/packages/database/migrations/sqlite/meta/0012_snapshot.json new file mode 100644 index 0000000..ca1ad6d --- /dev/null +++ b/packages/database/migrations/sqlite/meta/0012_snapshot.json @@ -0,0 +1,2084 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d679bf2e-911b-44ea-b7d5-82b6404e6a4e", + "prevId": "d063930a-ae25-4ca0-8b38-7092a16e3503", + "tables": { + "admin_providers": { + "name": "admin_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_settings": { + "name": "admin_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobook_chapters": { + "name": "audiobook_chapters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobook_chapters_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobooks": { + "name": "audiobooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_blob_leases": { + "name": "document_blob_leases", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_previews": { + "name": "document_previews", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + "status", + "lease_until_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "columns": [ + "document_id", + "namespace", + "variant" + ], + "name": "document_previews_document_id_namespace_variant_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_settings": { + "name": "document_settings", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "columns": [ + "document_id", + "user_id" + ], + "name": "document_settings_document_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recently_opened_at": { + "name": "recently_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + "user_id", + "last_modified" + ], + "isUnique": false + }, + "idx_documents_user_id_folder": { + "name": "idx_documents_user_id_folder", + "columns": [ + "user_id", + "folder_id" + ], + "isUnique": false + }, + "idx_documents_user_id_recently_opened": { + "name": "idx_documents_user_id_recently_opened", + "columns": [ + "user_id", + "recently_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_folder_id_user_id_user_folders_id_user_id_fk": { + "name": "documents_folder_id_user_id_user_folders_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user_folders", + "columnsFrom": [ + "folder_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_duration_ms": { + "name": "last_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_result_json": { + "name": "last_result_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_requested": { + "name": "run_requested", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "running_since": { + "name": "running_since", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "scheduled_tasks_interval_ms_positive": { + "name": "scheduled_tasks_interval_ms_positive", + "value": "\"scheduled_tasks\".\"interval_ms\" > 0" + } + } + }, + "tts_segment_entries": { + "name": "tts_segment_entries", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_version": { + "name": "document_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + "user_id", + "document_id", + "document_version", + "locator_reader_rank", + "locator_spine_index", + "locator_char_offset", + "locator_spine_href", + "locator_page", + "locator_location", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + "user_id", + "document_id", + "document_version", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + "user_id", + "document_id", + "document_version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "columns": [ + "segment_entry_id", + "user_id" + ], + "name": "tts_segment_entries_segment_entry_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_variants": { + "name": "tts_segment_variants", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + "user_id", + "segment_entry_id", + "updated_at" + ], + "isUnique": false + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + "user_id", + "segment_entry_id", + "settings_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "columns": [ + "segment_id", + "user_id" + ], + "name": "tts_segment_variants_segment_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_document_progress": { + "name": "user_document_progress", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "columns": [ + "user_id", + "document_id" + ], + "name": "user_document_progress_user_id_document_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_folders": { + "name": "user_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_folders_user_position": { + "name": "idx_user_folders_user_position", + "columns": [ + "user_id", + "position" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_folders_user_id_user_id_fk": { + "name": "user_folders_user_id_user_id_fk", + "tableFrom": "user_folders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_folders_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "user_folders_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_job_events": { + "name": "user_job_events", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "op_id": { + "name": "op_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_job_events_user_action_created": { + "name": "idx_user_job_events_user_action_created", + "columns": [ + "user_id", + "action", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_job_events_user_id_user_id_fk": { + "name": "user_job_events_user_id_user_id_fk", + "tableFrom": "user_job_events", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_job_events_user_id_action_op_id_pk": { + "columns": [ + "user_id", + "action", + "op_id" + ], + "name": "user_job_events_user_id_action_op_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_onboarding": { + "name": "user_onboarding", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "privacy_accepted_at_ms": { + "name": "privacy_accepted_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_app_version": { + "name": "last_seen_app_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "user_onboarding_user_id_user_id_fk": { + "name": "user_onboarding_user_id_user_id_fk", + "tableFrom": "user_onboarding", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tts_chars": { + "name": "user_tts_chars", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "user_tts_chars_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/sqlite/meta/_journal.json b/packages/database/migrations/sqlite/meta/_journal.json index 3222938..5df8cf2 100644 --- a/packages/database/migrations/sqlite/meta/_journal.json +++ b/packages/database/migrations/sqlite/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1780851107505, "tag": "0011_scheduled-tasks", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1781466219864, + "tag": "0012_user_additions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 5830ed0..3f65d80 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -7,11 +7,13 @@ const usePostgres = !!process.env.POSTGRES_URL; // and are NOT part of the Drizzle schema. Only app-specific tables are exported here. export const documents = usePostgres ? postgresSchema.documents : sqliteSchema.documents; +export const userFolders = usePostgres ? postgresSchema.userFolders : sqliteSchema.userFolders; export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema.audiobooks; export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters; export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars; export const userJobEvents = usePostgres ? postgresSchema.userJobEvents : sqliteSchema.userJobEvents; export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences; +export const userOnboarding = usePostgres ? postgresSchema.userOnboarding : sqliteSchema.userOnboarding; export const documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings; export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress; export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews; diff --git a/packages/database/src/schema_postgres.ts b/packages/database/src/schema_postgres.ts index b43b97b..6bb3676 100644 --- a/packages/database/src/schema_postgres.ts +++ b/packages/database/src/schema_postgres.ts @@ -4,6 +4,18 @@ import { user } from './schema_auth_postgres'; const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`; +export const userFolders = pgTable('user_folders', { + id: text('id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + position: integer('position').notNull().default(0), + createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), + updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), +}, (table) => [ + primaryKey({ columns: [table.id, table.userId] }), + index('idx_user_folders_user_position').on(table.userId, table.position), +]); + export const documents = pgTable('documents', { id: text('id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), @@ -12,11 +24,19 @@ export const documents = pgTable('documents', { size: bigint('size', { mode: 'number' }).notNull(), lastModified: bigint('last_modified', { mode: 'number' }).notNull(), filePath: text('file_path').notNull(), + folderId: text('folder_id'), + recentlyOpenedAt: bigint('recently_opened_at', { mode: 'number' }), createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), + foreignKey({ + columns: [table.folderId, table.userId], + foreignColumns: [userFolders.id, userFolders.userId], + }), index('idx_documents_user_id').on(table.userId), index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified), + index('idx_documents_user_id_folder').on(table.userId, table.folderId), + index('idx_documents_user_id_recently_opened').on(table.userId, table.recentlyOpenedAt), ]); export const audiobooks = pgTable('audiobooks', { @@ -89,6 +109,14 @@ export const userPreferences = pgTable('user_preferences', { updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), }); +export const userOnboarding = pgTable('user_onboarding', { + userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }), + privacyAcceptedAtMs: bigint('privacy_accepted_at_ms', { mode: 'number' }), + lastSeenAppVersion: text('last_seen_app_version'), + createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), + updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), +}); + export const documentSettings = pgTable('document_settings', { documentId: text('document_id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), diff --git a/packages/database/src/schema_sqlite.ts b/packages/database/src/schema_sqlite.ts index 12f7ab7..f722133 100644 --- a/packages/database/src/schema_sqlite.ts +++ b/packages/database/src/schema_sqlite.ts @@ -4,6 +4,18 @@ import { user } from './schema_auth_sqlite'; const SQLITE_NOW_MS = sql`(cast(unixepoch('subsecond') * 1000 as integer))`; +export const userFolders = sqliteTable('user_folders', { + id: text('id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + position: integer('position').notNull().default(0), + createdAt: integer('created_at').default(SQLITE_NOW_MS), + updatedAt: integer('updated_at').default(SQLITE_NOW_MS), +}, (table) => [ + primaryKey({ columns: [table.id, table.userId] }), + index('idx_user_folders_user_position').on(table.userId, table.position), +]); + export const documents = sqliteTable('documents', { id: text('id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), @@ -12,11 +24,19 @@ export const documents = sqliteTable('documents', { size: integer('size').notNull(), lastModified: integer('last_modified').notNull(), filePath: text('file_path').notNull(), + folderId: text('folder_id'), + recentlyOpenedAt: integer('recently_opened_at'), createdAt: integer('created_at').default(SQLITE_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), + foreignKey({ + columns: [table.folderId, table.userId], + foreignColumns: [userFolders.id, userFolders.userId], + }), index('idx_documents_user_id').on(table.userId), index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified), + index('idx_documents_user_id_folder').on(table.userId, table.folderId), + index('idx_documents_user_id_recently_opened').on(table.userId, table.recentlyOpenedAt), ]); export const audiobooks = sqliteTable('audiobooks', { @@ -89,6 +109,14 @@ export const userPreferences = sqliteTable('user_preferences', { updatedAt: integer('updated_at').default(SQLITE_NOW_MS), }); +export const userOnboarding = sqliteTable('user_onboarding', { + userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }), + privacyAcceptedAtMs: integer('privacy_accepted_at_ms'), + lastSeenAppVersion: text('last_seen_app_version'), + createdAt: integer('created_at').default(SQLITE_NOW_MS), + updatedAt: integer('updated_at').default(SQLITE_NOW_MS), +}); + export const documentSettings = sqliteTable('document_settings', { documentId: text('document_id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ca9ff3..0164781 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,12 +59,6 @@ importers: core-js: specifier: ^3.49.0 version: 3.49.0 - dexie: - specifier: ^4.4.3 - version: 4.4.3 - dexie-react-hooks: - specifier: ^4.4.0 - version: 4.4.0(dexie@4.4.3)(react@19.2.7) dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -2606,15 +2600,6 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - dexie-react-hooks@4.4.0: - resolution: {integrity: sha512-ObLXBS5+4BJU8vtSvBx6b9fY6zZYgniAtwxzjCHsUQadgbqYN6935X2/1TWw4Rf2N1aZV1io5/ziox4vKuxABA==} - peerDependencies: - dexie: '>=4.2.0-alpha.1 <5.0.0' - react: '>=16' - - dexie@4.4.3: - resolution: {integrity: sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==} - didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -7089,13 +7074,6 @@ snapshots: dependencies: dequal: 2.0.3 - dexie-react-hooks@4.4.0(dexie@4.4.3)(react@19.2.7): - dependencies: - dexie: 4.4.3 - react: 19.2.7 - - dexie@4.4.3: {} - didyoumean@1.2.2: {} dlv@1.1.3: {} diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 38472f8..8147a81 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from 'react'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { EPUBViewer } from '@/components/views/EPUBViewer'; @@ -14,7 +14,6 @@ import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; -import { resolveDocumentId } from '@/lib/client/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; @@ -26,7 +25,6 @@ import { useEpubDocument } from './useEpubDocument'; export default function EPUBPage() { const canExportAudiobook = useFeatureFlag('enableAudiobookExport'); const { id } = useParams(); - const router = useRouter(); const routeDocumentId = typeof id === 'string' ? id : undefined; const epubState = useEpubDocument(routeDocumentId); const { @@ -62,19 +60,13 @@ export default function EPUBPage() { const loadDocument = useCallback(async () => { console.log('Loading new epub (from page.tsx)'); - let didRedirect = false; let startedLoad = false; try { if (!routeDocumentId) { setError('Document not found'); return; } - const resolved = await resolveDocumentId(routeDocumentId); - if (resolved !== routeDocumentId) { - didRedirect = true; - router.replace(`/epub/${resolved}`); - return; - } + const resolved = routeDocumentId; if (loadedDocIdRef.current === resolved) { return; @@ -95,11 +87,11 @@ export default function EPUBPage() { if (startedLoad) { inFlightDocIdRef.current = null; } - if (!didRedirect && startedLoad) { + if (startedLoad) { setIsLoading(false); } } - }, [routeDocumentId, router, setCurrentDocument, stop]); + }, [routeDocumentId, setCurrentDocument, stop]); useEffect(() => { if (!isLoading) return; diff --git a/src/app/(app)/epub/[id]/useEpubDocument.ts b/src/app/(app)/epub/[id]/useEpubDocument.ts index ee5167f..c67e6d1 100644 --- a/src/app/(app)/epub/[id]/useEpubDocument.ts +++ b/src/app/(app)/epub/[id]/useEpubDocument.ts @@ -115,8 +115,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { } = useTTS(); // Configuration context to get TTS settings const { - apiKey, - baseUrl, providerRef, ttsSegmentMaxBlockLength, epubTheme, @@ -195,7 +193,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { }, [resetHighlightState, setCurrDocPages, stop]); /** - * Sets the current document based on its ID by fetching from IndexedDB + * Sets the current document based on its ID using server metadata and the browser blob cache. * @param {string} id - The unique identifier of the document * @throws {Error} When document data is empty or retrieval fails */ @@ -478,8 +476,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const { createFullAudioBook, regenerateChapter } = useEPUBAudiobook({ bookRef, tocRef, - apiKey, - baseUrl, providerRef, }); diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index 6d97618..5314dcd 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from 'react'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { HTMLViewer } from '@/components/views/HTMLViewer'; @@ -9,7 +9,6 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; -import { resolveDocumentId } from '@/lib/client/dexie'; import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu'; import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; @@ -26,7 +25,6 @@ import { useHtmlDocument } from './useHtmlDocument'; export default function HTMLPage() { const canExportAudiobook = useFeatureFlag('enableAudiobookExport'); const { id } = useParams(); - const router = useRouter(); const routeDocumentId = typeof id === 'string' ? id : undefined; const htmlState = useHtmlDocument(); const { @@ -63,19 +61,13 @@ export default function HTMLPage() { const loadDocument = useCallback(async () => { if (!isLoading) return; console.log('Loading new HTML document (from page.tsx)'); - let didRedirect = false; let startedLoad = false; try { if (!id) { setError('Document not found'); return; } - const resolved = await resolveDocumentId(id as string); - if (resolved !== (id as string)) { - didRedirect = true; - router.replace(`/html/${resolved}`); - return; - } + const resolved = id as string; if (loadedDocIdRef.current === resolved) { return; @@ -96,11 +88,11 @@ export default function HTMLPage() { if (startedLoad) { inFlightDocIdRef.current = null; } - if (!didRedirect && startedLoad) { + if (startedLoad) { setIsLoading(false); } } - }, [isLoading, id, router, setCurrentDocument, stop]); + }, [isLoading, id, setCurrentDocument, stop]); useEffect(() => { if (!isLoading) return; diff --git a/src/app/(app)/html/[id]/useHtmlDocument.ts b/src/app/(app)/html/[id]/useHtmlDocument.ts index 3b91553..8543b06 100644 --- a/src/app/(app)/html/[id]/useHtmlDocument.ts +++ b/src/app/(app)/html/[id]/useHtmlDocument.ts @@ -66,8 +66,6 @@ function buildFullDocumentText(blocks: HtmlBlock[]): string { export function useHtmlDocument(): HtmlDocumentState { const { setText: setTTSText, stop, setIsEPUB } = useTTS(); const { - apiKey, - baseUrl, providerRef, ttsSegmentMaxBlockLength, } = useConfig(); @@ -173,8 +171,6 @@ export function useHtmlDocument(): HtmlDocumentState { try { return await runAudiobookGeneration({ adapter: audiobookAdapter, - apiKey, - baseUrl, defaultProvider: providerRef, onProgress, signal, @@ -189,7 +185,7 @@ export function useHtmlDocument(): HtmlDocumentState { throw error; } }, - [audiobookAdapter, apiKey, baseUrl, providerRef], + [audiobookAdapter, providerRef], ); const regenerateChapter = useCallback( @@ -208,8 +204,6 @@ export function useHtmlDocument(): HtmlDocumentState { bookId, format, signal, - apiKey, - baseUrl, defaultProvider: providerRef, settings, retryOptions, @@ -222,7 +216,7 @@ export function useHtmlDocument(): HtmlDocumentState { throw error; } }, - [audiobookAdapter, apiKey, baseUrl, providerRef], + [audiobookAdapter, providerRef], ); return useMemo( diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index ebb1972..0ddd4e9 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -34,12 +34,7 @@ export default function AppLayout({ children }: { children: ReactNode }) { allowAnonymousAuthSessions={allowAnonymousAuthSessions} githubAuthEnabled={githubAuthEnabled} > - {/* ConfigProvider lives here, in the shared (app) layout, so it stays - mounted across library <-> reader navigation. Mounting it per-route - re-ran the Dexie/server hydration race on every navigation, causing - the reader to briefly use the admin-default provider until a full - page refresh. A single shared instance keeps the user's saved - provider hydrated the whole time. */} + {/* Keep the preferences query/context mounted across library and reader navigation. */} {children} diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 8fd9b54..7540377 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -14,7 +14,6 @@ import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import TTSPlayer from '@/components/player/TTSPlayer'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; -import { resolveDocumentId } from '@/lib/client/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; @@ -99,7 +98,6 @@ export default function PDFViewerPage() { const loadDocument = useCallback(async () => { if (!isLoading) return; // Prevent calls when not loading new doc console.log('Loading new document (from page.tsx)'); - let didRedirect = false; let startedLoad = false; let loadSucceeded = false; try { @@ -107,12 +105,7 @@ export default function PDFViewerPage() { setError('Document not found'); return; } - const resolved = await resolveDocumentId(id as string); - if (resolved !== (id as string)) { - didRedirect = true; - router.replace(`/pdf/${resolved}`); - return; - } + const resolved = id as string; if (loadedDocIdRef.current === resolved) { return; @@ -125,12 +118,18 @@ export default function PDFViewerPage() { inFlightDocIdRef.current = resolved; stop(); // Reset TTS when loading new document for (let attempt = 0; attempt < 2; attempt += 1) { - const loaded = await setCurrentDocument(resolved); - if (loaded) { + const result = await setCurrentDocument(resolved); + if (result === 'loaded') { loadSucceeded = true; loadedDocIdRef.current = resolved; break; } + if (result === 'superseded') { + // A newer load (or unmount) is now authoritative; it owns the loading + // lifecycle. Bail without surfacing an error to avoid the spurious + // "Failed to load" screen on first launch. + return; + } if (attempt === 0) { await new Promise((resolve) => setTimeout(resolve, 250)); } @@ -145,11 +144,11 @@ export default function PDFViewerPage() { if (startedLoad) { inFlightDocIdRef.current = null; } - if (!didRedirect && startedLoad && loadSucceeded) { + if (startedLoad && loadSucceeded) { setIsLoading(false); } } - }, [isLoading, id, router, setCurrentDocument, stop]); + }, [isLoading, id, setCurrentDocument, stop]); useEffect(() => { loadDocument(); diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 86cebf9..5be4ed4 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -22,10 +22,8 @@ import { ensureParsedPdfDocumentOperation, forceReparsePdfDocument, getDocumentMetadata, - getDocumentSettings, getParsedPdfDocument, ParsedPdfNotReadyError, - putDocumentSettings, subscribeParsedPdfDocumentEvents, } from '@/lib/client/api/documents'; import { createPdfAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/pdf'; @@ -56,6 +54,16 @@ import type { } from '@/types/tts'; import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client'; import { clampSegmentPreloadDepth } from '@/types/config'; +import { useDocumentSettings } from '@/hooks/useDocumentSettings'; + +/** + * Outcome of a `setCurrentDocument` call. + * - `loaded`: the document was fetched and is now the active document. + * - `superseded`: the load was aborted/replaced by a newer load (or unmount). + * A newer load is authoritative; callers must NOT treat this as an error. + * - `failed`: a genuine failure (not found, wrong type, network error). + */ +export type SetCurrentDocumentResult = 'loaded' | 'superseded' | 'failed'; /** * Interface defining all available methods and properties for the PDF route. @@ -78,7 +86,7 @@ export interface PdfDocumentState { parsedOverlayEnabled: boolean; setParsedOverlayEnabled: (enabled: boolean) => void; forceReparseParsedPdf: () => Promise; - setCurrentDocument: (id: string) => Promise; + setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; // PDF functionality @@ -139,8 +147,6 @@ export function usePdfDocument(): PdfDocumentState { registerVisualPageChangeHandler, } = useTTS(); const { - apiKey, - baseUrl, providerRef, segmentPreloadDepthPages, ttsSegmentMaxBlockLength, @@ -158,6 +164,11 @@ export function usePdfDocument(): PdfDocumentState { const [parseProgress, setParseProgress] = useState(null); const [, setActiveParseOpId] = useState(null); const [documentSettings, setDocumentSettings] = useState(DEFAULT_DOCUMENT_SETTINGS); + const serverDocumentSettings = useDocumentSettings(currDocId); + useEffect(() => { + if (!serverDocumentSettings.query.data?.settings) return; + setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, serverDocumentSettings.query.data.settings)); + }, [serverDocumentSettings.query.data?.settings]); useEffect(() => { setDocumentLanguage(documentSettings.language ?? 'auto'); lastPreparedPlaybackPageRef.current = null; @@ -206,17 +217,6 @@ export function usePdfDocument(): PdfDocumentState { pageTextCacheRef.current.clear(); }, []); - const fetchDocumentSettings = useCallback(async (documentId: string, signal: AbortSignal): Promise => { - try { - const response = await getDocumentSettings(documentId, { signal }); - setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings)); - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') return; - console.warn('Failed to load document settings, using defaults:', error); - setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); - } - }, []); - const startParsedEventStream = useCallback((documentId: string, initialOpId: string) => { parseStreamAbortRef.current?.abort(); parseSseCloseRef.current?.(); @@ -516,12 +516,12 @@ export function usePdfDocument(): PdfDocumentState { /** * Sets the current document based on its ID - * Retrieves document from IndexedDB + * Retrieves document from server metadata and the browser blob cache. * * @param {string} id - The unique identifier of the document to set * @returns {Promise} */ - const setCurrentDocument = useCallback(async (id: string): Promise => { + const setCurrentDocument = useCallback(async (id: string): Promise => { // --- race-condition guard --- const seq = ++docLoadSeqRef.current; docLoadAbortRef.current?.abort(); @@ -554,13 +554,12 @@ export function usePdfDocument(): PdfDocumentState { setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); const meta = await getDocumentMetadata(id, { signal: controller.signal }); - if (seq !== docLoadSeqRef.current) return false; // stale + if (seq !== docLoadSeqRef.current) return 'superseded'; // a newer load took over if (!meta) { console.error('Document not found on server'); - return false; + return 'failed'; } if (meta.type === 'pdf') { - void fetchDocumentSettings(id, controller.signal); void resolveParsedDocumentState(id, controller.signal).catch((error) => { if (controller.signal.aborted) return; console.error('Failed to resolve parsed PDF state:', error); @@ -568,28 +567,29 @@ export function usePdfDocument(): PdfDocumentState { } const doc = await ensureCachedDocument(meta, { signal: controller.signal }); - if (seq !== docLoadSeqRef.current) return false; // stale + if (seq !== docLoadSeqRef.current) return 'superseded'; // a newer load took over if (doc.type !== 'pdf') { console.error('Document is not a PDF'); - return false; + return 'failed'; } setCurrDocName(doc.name); // IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the // buffer passed into the worker; we always pass clones to react-pdf. setCurrDocData(doc.data.slice(0)); - return true; + return 'loaded'; } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') return false; + // An aborted load means a newer selection (or unmount) took over; not a failure. + if (error instanceof DOMException && error.name === 'AbortError') return 'superseded'; + if (controller.signal.aborted) return 'superseded'; console.error('Failed to get document:', error); - return false; + return 'failed'; } finally { // Clean up the controller only if it's still ours (a newer call hasn't replaced it). if (docLoadAbortRef.current === controller) { docLoadAbortRef.current = null; } } - return false; }, [ setCurrDocId, setCurrDocName, @@ -597,7 +597,6 @@ export function usePdfDocument(): PdfDocumentState { setCurrDocPages, setCurrDocText, setPdfDocument, - fetchDocumentSettings, resolveParsedDocumentState, ]); @@ -605,12 +604,11 @@ export function usePdfDocument(): PdfDocumentState { if (!currDocId) return; setDocumentSettings(settings); try { - const updated = await putDocumentSettings(currDocId, settings); - setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, updated.settings)); + await serverDocumentSettings.mutation.mutateAsync(settings); } catch (error) { console.warn('Failed to persist document settings:', error); } - }, [currDocId]); + }, [currDocId, serverDocumentSettings.mutation]); const forceReparseParsedPdf = useCallback(async (): Promise => { if (!currDocId) return; @@ -684,8 +682,6 @@ export function usePdfDocument(): PdfDocumentState { try { return await runAudiobookGeneration({ adapter: audiobookAdapter, - apiKey, - baseUrl, defaultProvider: providerRef, onProgress, signal, @@ -698,7 +694,7 @@ export function usePdfDocument(): PdfDocumentState { console.error('Error creating audiobook:', error); throw error; } - }, [audiobookAdapter, apiKey, baseUrl, providerRef]); + }, [audiobookAdapter, providerRef]); /** * Regenerates a specific chapter (page) of the PDF audiobook @@ -717,8 +713,6 @@ export function usePdfDocument(): PdfDocumentState { bookId, format, signal, - apiKey, - baseUrl, defaultProvider: providerRef, settings, }); @@ -729,7 +723,7 @@ export function usePdfDocument(): PdfDocumentState { console.error('Error regenerating page:', error); throw error; } - }, [audiobookAdapter, apiKey, baseUrl, providerRef]); + }, [audiobookAdapter, providerRef]); /** * Effect hook to initialize TTS as non-EPUB mode diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index f080fbe..2cc056f 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -477,10 +477,7 @@ export async function POST(request: NextRequest) { providerForError = requestedProvider; const credResolved = await resolveTtsCredentials({ providerHeader: requestedProvider, - apiKeyHeader: request.headers.get('x-openai-key'), - baseUrlHeader: request.headers.get('x-openai-base-url'), fallbackProvider: runtimeConfig.defaultTtsProvider, - restrictUserApiKeys: runtimeConfig.restrictUserApiKeys, }); if ('error' in credResolved) { if (credResolved.error === 'no_shared_provider_configured') { diff --git a/src/app/api/documents/[id]/opened/route.ts b/src/app/api/documents/[id]/opened/route.ts new file mode 100644 index 0000000..c864d3e --- /dev/null +++ b/src/app/api/documents/[id]/opened/route.ts @@ -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' }, + }); + } +} diff --git a/src/app/api/documents/blob/preview/ensure/route.ts b/src/app/api/documents/blob/preview/ensure/route.ts index 1fa3450..6638aab 100644 --- a/src/app/api/documents/blob/preview/ensure/route.ts +++ b/src/app/api/documents/blob/preview/ensure/route.ts @@ -45,6 +45,7 @@ export async function GET(req: NextRequest) { status: 'ready', presignUrl, fallbackUrl, + previewVersion: preview.eTag || String(doc.lastModified), ...(directUrl ? { directUrl } : {}), }, { diff --git a/src/app/api/documents/folders/route.ts b/src/app/api/documents/folders/route.ts new file mode 100644 index 0000000..4c1240c --- /dev/null +++ b/src/app/api/documents/folders/route.ts @@ -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' }, + }); + } +} diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 328591f..90ed50d 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -64,6 +64,8 @@ export async function GET(req: NextRequest) { size: number; lastModified: number; filePath: string; + folderId: string | null; + recentlyOpenedAt: number | null; }>; const results: BaseDocument[] = rows.map((doc) => { @@ -75,6 +77,9 @@ export async function GET(req: NextRequest) { lastModified: Number(doc.lastModified), type, scope: 'user', + folderId: doc.folderId ?? undefined, + recentlyOpenedAt: doc.recentlyOpenedAt == null ? undefined : Number(doc.recentlyOpenedAt), + contentVersion: doc.id, }; }); diff --git a/src/app/api/folders/[id]/route.ts b/src/app/api/folders/[id]/route.ts new file mode 100644 index 0000000..3e6a6e7 --- /dev/null +++ b/src/app/api/folders/[id]/route.ts @@ -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' }, + }); + } +} diff --git a/src/app/api/folders/route.ts b/src/app/api/folders/route.ts new file mode 100644 index 0000000..1f9b24e --- /dev/null +++ b/src/app/api/folders/route.ts @@ -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' }, + }); + } +} diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index f93e551..cb81c75 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -183,10 +183,7 @@ export async function POST(request: NextRequest) { }); const requestCreds = await resolveTtsCredentials({ providerHeader: parsed.settings.providerRef, - apiKeyHeader: request.headers.get('x-openai-key'), - baseUrlHeader: request.headers.get('x-openai-base-url'), fallbackProvider: runtimeConfig.defaultTtsProvider, - restrictUserApiKeys: runtimeConfig.restrictUserApiKeys, }); if ('error' in requestCreds) { const status = requestCreds.error === 'no_shared_provider_configured' ? 503 : 404; diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 1c31b39..991167e 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -20,10 +20,7 @@ export async function GET(req: NextRequest) { const runtimeConfig = await getResolvedRuntimeConfig(); const resolved = await resolveTtsCredentials({ providerHeader: req.headers.get('x-tts-provider'), - apiKeyHeader: req.headers.get('x-openai-key'), - baseUrlHeader: req.headers.get('x-openai-base-url'), fallbackProvider: runtimeConfig.defaultTtsProvider, - restrictUserApiKeys: runtimeConfig.restrictUserApiKeys, }); if ('error' in resolved) { diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index 8d4b918..b89d653 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { claimAnonymousData } from '@/lib/server/user/claim-data'; import { auth } from '@/lib/server/auth/auth'; import { db } from '@openreader/database'; -import { audiobooks, documentSettings, documents, userDocumentProgress, userPreferences } from '@openreader/database/schema'; +import { audiobooks, documentSettings, documents, userDocumentProgress, userFolders, userOnboarding, userPreferences } from '@openreader/database/schema'; import { count, eq, ne } from 'drizzle-orm'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { errorToLog, serverLogger } from '@/lib/server/logger'; @@ -26,14 +26,16 @@ async function checkClaimMigrationReadiness(): Promise { async function getClaimableCounts( unclaimedUserId: string, -): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number }> { - const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount]] = +): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number; folders: number; onboarding: number }> { + const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount], [folderCount], [onboardingCount]] = await Promise.all([ db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)), db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)), db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)), db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)), db.select({ count: count() }).from(documentSettings).where(eq(documentSettings.userId, unclaimedUserId)), + db.select({ count: count() }).from(userFolders).where(eq(userFolders.userId, unclaimedUserId)), + db.select({ count: count() }).from(userOnboarding).where(eq(userOnboarding.userId, unclaimedUserId)), ]); return { @@ -42,6 +44,8 @@ async function getClaimableCounts( preferences: Number(preferencesCount?.count ?? 0), progress: Number(progressCount?.count ?? 0), documentSettings: Number(settingsCount?.count ?? 0), + folders: Number(folderCount?.count ?? 0), + onboarding: Number(onboardingCount?.count ?? 0), }; } diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts index eae1047..605b950 100644 --- a/src/app/api/user/export/route.ts +++ b/src/app/api/user/export/route.ts @@ -12,6 +12,8 @@ import { userDocumentProgress, userJobEvents, userPreferences, + userFolders, + userOnboarding, userTtsChars, } from '@openreader/database/schema'; import * as authSchemaSqlite from '@openreader/database/schema-auth-sqlite'; @@ -65,6 +67,8 @@ export async function GET(req: NextRequest) { segmentVariants, userDocs, userAudiobooks, + folders, + onboarding, ] = await Promise.all([ db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1), db @@ -107,6 +111,8 @@ export async function GET(req: NextRequest) { .from(audiobooks) .where(eq(audiobooks.userId, userId)) .orderBy(desc(audiobooks.createdAt)), + db.select().from(userFolders).where(eq(userFolders.userId, userId)).orderBy(userFolders.position), + db.select().from(userOnboarding).where(eq(userOnboarding.userId, userId)).limit(1), ]); const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite; @@ -190,6 +196,8 @@ export async function GET(req: NextRequest) { exportedAtMs, profileData, preferences: prefs[0] ?? null, + folders, + onboarding: onboarding[0] ?? null, readingHistory: progress, ttsUsage, jobEvents, diff --git a/src/app/api/user/state/changelog/version-check/route.ts b/src/app/api/user/state/changelog/version-check/route.ts index 498467b..b25a8b1 100644 --- a/src/app/api/user/state/changelog/version-check/route.ts +++ b/src/app/api/user/state/changelog/version-check/route.ts @@ -1,26 +1,15 @@ import { eq } from 'drizzle-orm'; import { NextRequest, NextResponse } from 'next/server'; import { db } from '@openreader/database'; -import { userPreferences } from '@openreader/database/schema'; +import { userOnboarding } from '@openreader/database/schema'; import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog'; import { nowTimestampMs } from '@/lib/shared/timestamps'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; -import { - deserializeUserPreferencesPayload, - extractUserPreferencesMeta, - withUserPreferencesMeta, - USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY, -} from '@/lib/server/user/preferences-payload'; export const dynamic = 'force-dynamic'; -function serializePreferencesForDb(payload: Record): Record | string { - if (process.env.POSTGRES_URL) return payload; - return JSON.stringify(payload); -} - export async function POST(req: NextRequest) { try { const scope = await resolveUserStateScope(req); @@ -38,40 +27,28 @@ export async function POST(req: NextRequest) { const rows = await db .select({ - dataJson: userPreferences.dataJson, - clientUpdatedAtMs: userPreferences.clientUpdatedAtMs, + lastSeenAppVersion: userOnboarding.lastSeenAppVersion, }) - .from(userPreferences) - .where(eq(userPreferences.userId, scope.ownerUserId)) + .from(userOnboarding) + .where(eq(userOnboarding.userId, scope.ownerUserId)) .limit(1); const row = rows[0]; - const existingPayload = deserializeUserPreferencesPayload(row?.dataJson); - const existingMeta = extractUserPreferencesMeta(existingPayload); - const lastSeenVersion = typeof existingMeta.lastSeenAppVersion === 'string' - ? existingMeta.lastSeenAppVersion - : null; + const lastSeenVersion = row?.lastSeenAppVersion ?? null; const shouldOpen = shouldOpenChangelogForVersionChange(lastSeenVersion, currentVersion); - const nextMeta = { - ...existingMeta, - [USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY]: currentVersion, - }; - const dataJson = serializePreferencesForDb(withUserPreferencesMeta(existingPayload, nextMeta)); const updatedAt = nowTimestampMs(); - const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0); await db - .insert(userPreferences) + .insert(userOnboarding) .values({ userId: scope.ownerUserId, - dataJson, - clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt, + lastSeenAppVersion: currentVersion, updatedAt, }) .onConflictDoUpdate({ - target: [userPreferences.userId], + target: [userOnboarding.userId], set: { - dataJson, + lastSeenAppVersion: currentVersion, updatedAt, }, }); diff --git a/src/app/api/user/state/onboarding/route.ts b/src/app/api/user/state/onboarding/route.ts new file mode 100644 index 0000000..451b5c3 --- /dev/null +++ b/src/app/api/user/state/onboarding/route.ts @@ -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' }, + }); + } +} diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 4c81de2..98d684f 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -13,12 +13,6 @@ import { sanitizePreferencesPatch, type PreferenceNormalizationContext, } from '@/lib/server/user/preferences-normalize'; -import { - deserializeUserPreferencesPayload, - extractUserPreferencesMeta, - withUserPreferencesMeta, - type UserPreferencesMeta, -} from '@/lib/server/user/preferences-payload'; export const dynamic = 'force-dynamic'; @@ -27,6 +21,22 @@ function serializePreferencesForDb(payload: Record): Record { + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } + } + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + async function loadPreferenceNormalizationContext(): Promise { const [runtimeConfig, providers] = await Promise.all([ getResolvedRuntimeConfig(), @@ -49,11 +59,10 @@ async function loadPreferenceNormalizationContext(): Promise 0 ? clientUpdatedAtMs : updatedAt, updatedAt, }) .onConflictDoUpdate({ target: [userPreferences.userId], set: { - dataJson: serializePreferencesForDb(storedPayload), + dataJson: serializePreferencesForDb(storedPatch), updatedAt, }, }); @@ -161,8 +169,7 @@ export async function PUT(req: NextRequest) { } const mergedPatch = { ...existingPatch, ...patch }; - const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta); - const dataJson = serializePreferencesForDb(payloadWithMeta); + const dataJson = serializePreferencesForDb(mergedPatch); const updatedAt = nowTimestampMs(); await db diff --git a/src/app/providers.tsx b/src/app/providers.tsx index b8bc6f7..d9a9df0 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactNode, useState } from 'react'; +import { ReactNode, useEffect, useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '@/contexts/ThemeContext'; @@ -25,6 +25,15 @@ export function Providers({ children, authBaseUrl, allowAnonymousAuthSessions, g }, })); + useEffect(() => { + if (typeof indexedDB === 'undefined') return; + try { + indexedDB.deleteDatabase('openreader-db'); + } catch { + // Legacy IndexedDB cleanup is best effort and never blocks startup. + } + }, []); + return ( diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx index a82e172..0ef0302 100644 --- a/src/components/PrivacyModal.tsx +++ b/src/components/PrivacyModal.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { updateAppConfig } from '@/lib/client/dexie'; +import { useOnboardingState } from '@/hooks/useOnboardingState'; import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui'; interface PrivacyModalProps { @@ -47,6 +47,7 @@ function PrivacyModalBody({ origin }: { origin: string }) { } export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) { + const { mutation } = useOnboardingState(); const [origin, setOrigin] = useState(''); const [agreed, setAgreed] = useState(false); @@ -62,7 +63,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) }, [isOpen]); const handleAccept = async () => { - await updateAppConfig({ privacyAccepted: true }); + await mutation.mutateAsync({ privacyAccepted: true }); if (typeof window !== 'undefined') { window.dispatchEvent(new Event('openreader:privacyAccepted')); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index fb80ca3..c6088a5 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -22,13 +22,8 @@ import { mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; +import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { - isBuiltInTtsProviderId, - type TtsProviderType, -} from '@/lib/shared/tts-provider-catalog'; -import { - defaultBaseUrlForProviderType, - defaultModelForProviderType, resolveProviderDefaults, resolveEffectiveProviderType, resolveTtsProviderModelPolicy, @@ -38,8 +33,6 @@ import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel'; import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel'; import { AdminTasksPanel } from '@/components/admin/AdminTasksPanel'; import { useSharedProviders } from '@/hooks/useSharedProviders'; -import { flushUserPreferencesSync } from '@/lib/client/api/user-state'; -import toast from 'react-hot-toast'; import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery'; import { SidebarDialog, @@ -219,7 +212,6 @@ export function SettingsModal({ const runtimeConfig = useRuntimeConfig(); const showAllProviderModels = runtimeConfig.showAllProviderModels; const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab; - const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys; const isOpen = open; const setIsOpen = onOpenChange; const [isChangelogOpen, setIsChangelogOpen] = useState(false); @@ -228,10 +220,8 @@ export function SettingsModal({ const { theme, setTheme, applyCustomColors } = useTheme(); const [customColors, setCustomColors] = useState(getCustomThemeColors); const [isCustomExpanded, setIsCustomExpanded] = useState(false); - const { apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); + const { providerRef, providerType, ttsModel, ttsInstructions, updateConfigKey } = useConfig(); const { refreshDocuments } = useDocuments(); - const [localApiKey, setLocalApiKey] = useState(apiKey); - const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl); const [localProviderRef, setLocalProviderRef] = useState(providerRef); const [localProviderType, setLocalProviderType] = useState(providerType); const [modelValue, setModelValue] = useState(ttsModel); @@ -279,13 +269,12 @@ export function SettingsModal({ } = useMemo(() => resolveTtsSettingsViewModel({ providerRef: localProviderRef, providerType: localProviderType, - apiKey: localApiKey, modelValue, customModelInput, showAllProviderModels, sharedProviders, - allowBuiltInProviders: !restrictUserApiKeys, - }), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllProviderModels, sharedProviders, restrictUserApiKeys]); + allowBuiltInProviders: false, + }), [localProviderRef, localProviderType, modelValue, customModelInput, showAllProviderModels, sharedProviders]); const isSharedSelected = Boolean(selectedSharedProvider); const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0]; @@ -301,13 +290,11 @@ export function SettingsModal({ // focus refetch in ConfigContext, or async shared-provider loading) must not // stomp their in-progress selection. On close, resetToCurrent re-syncs. if (isOpen) return; - setLocalApiKey(apiKey); - setLocalBaseUrl(baseUrl); setLocalProviderRef(providerRef); setLocalProviderType(providerType); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); - }, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); + }, [isOpen, providerRef, providerType, ttsModel, ttsInstructions]); useEffect(() => { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { @@ -331,17 +318,9 @@ export function SettingsModal({ setModelValue(shared.defaultModel); } setLocalTTSInstructions(shared?.defaultInstructions ?? ''); - setLocalApiKey(''); - setLocalBaseUrl(''); setCustomModelInput(''); return; } - - if (isBuiltInTtsProviderId(fallback.providerType)) { - setModelValue(defaultModelForProviderType(fallback.providerType)); - setLocalBaseUrl(defaultBaseUrlForProviderType(fallback.providerType)); - setCustomModelInput(''); - } }, [selectedProviderOption, ttsProviders, sharedProviders]); const handleRefresh = async () => { @@ -459,19 +438,9 @@ export function SettingsModal({ setShowDeleteAccountConfirm(false); }; - const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => { - if (type === 'apiKey') { - setLocalApiKey(value === '' ? '' : value); - } else if (type === 'baseUrl') { - setLocalBaseUrl(value === '' ? '' : value); - } - }; - const resetToCurrent = useCallback(() => { setIsOpen(false); setIsChangelogOpen(false); - setLocalApiKey(apiKey); - setLocalBaseUrl(baseUrl); setLocalProviderRef(providerRef); setLocalProviderType(providerType); setModelValue(ttsModel); @@ -481,7 +450,7 @@ export function SettingsModal({ } else { setCustomModelInput(''); } - }, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]); + }, [providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]); const [systemIsDark, setSystemIsDark] = useState( typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches @@ -546,13 +515,6 @@ export function SettingsModal({ model: modelValue, sharedProviders, }); - const shouldShowBaseUrl = !restrictUserApiKeys - && !isSharedSelected - && providerModelPolicy.isResolvedProviderType - && providerModelPolicy.providerType !== 'replicate' - && providerModelPolicy.providerType !== 'speech-sdk' - && (providerModelPolicy.providerType === 'custom-openai' || !localBaseUrl || localBaseUrl === ''); - const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected; const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]; const selectedModelVersion = selectedModel?.id?.includes(':') ? selectedModel.id.slice(selectedModel.id.indexOf(':')) @@ -642,53 +604,11 @@ export function SettingsModal({ setLocalProviderType(defaults.providerType); setModelValue(defaults.defaultModel); setLocalTTSInstructions(defaults.defaultInstructions); - if (provider.shared) { - // Shared admin provider — credentials live on the server. - setLocalApiKey(''); - setLocalBaseUrl(''); - } else if (isBuiltInTtsProviderId(provider.providerType)) { - setLocalBaseUrl(defaultBaseUrlForProviderType(provider.providerType)); - } setCustomModelInput(''); }} /> )} - {restrictUserApiKeys && ( -

- This instance restricts user API keys. TTS runs through admin-configured shared providers only. -

- )} - - {shouldShowBaseUrl && ( -
- - handleInputChange('baseUrl', e.target.value)} - placeholder="Using environment variable" - /> -
- )} - - {shouldShowApiKey && ( -
- - handleInputChange('apiKey', e.target.value)} - placeholder="Using environment variable" - /> -
- )} {isSharedSelected && (

This is a shared provider configured by an admin. API key and base URL are managed server-side. @@ -773,8 +693,6 @@ export function SettingsModal({ providerRef: runtimeConfig.defaultTtsProvider, sharedProviders, }); - setLocalApiKey(''); - setLocalBaseUrl(''); setLocalProviderRef(defaults.providerRef); setLocalProviderType(defaults.providerType); setModelValue(defaults.defaultModel); @@ -796,10 +714,6 @@ export function SettingsModal({ providerType: selectedProviderType, sharedProviders, }); - await updateConfig({ - apiKey: restrictUserApiKeys ? '' : (localApiKey || ''), - baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''), - }); await updateConfigKey('providerRef', selectedProviderRef); await updateConfigKey('providerType', selectedProviderType); const finalModel = showAllProviderModels @@ -807,16 +721,6 @@ export function SettingsModal({ : defaults.defaultModel; await updateConfigKey('ttsModel', finalModel); await updateConfigKey('ttsInstructions', localTTSInstructions); - // Push the change to the server immediately rather than waiting on - // the debounce, so a quick reload can't lose the save. Surface - // failures instead of silently dropping them. - try { - await flushUserPreferencesSync(); - } catch (error) { - console.error('Failed to save TTS provider settings:', error); - toast.error('Failed to save provider settings. Please try again.'); - return; - } setIsOpen(false); }} > diff --git a/src/components/auth/ClaimDataModal.tsx b/src/components/auth/ClaimDataModal.tsx index f1338da..faefb2d 100644 --- a/src/components/auth/ClaimDataModal.tsx +++ b/src/components/auth/ClaimDataModal.tsx @@ -11,6 +11,8 @@ export type ClaimableCounts = { preferences: number; progress: number; documentSettings: number; + folders: number; + onboarding: number; }; function toClaimableCounts(value: unknown): ClaimableCounts { @@ -21,6 +23,8 @@ function toClaimableCounts(value: unknown): ClaimableCounts { preferences: Number(rec.preferences ?? 0), progress: Number(rec.progress ?? 0), documentSettings: Number(rec.documentSettings ?? 0), + folders: Number(rec.folders ?? 0), + onboarding: Number(rec.onboarding ?? 0), }; } @@ -86,6 +90,8 @@ export default function ClaimDataModal({

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

    setShowHint(false)} + onClick={handleDismissHint} size="xs" className="h-6 w-6" aria-label="Dismiss hint" diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index aea8b53..757563a 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -132,7 +132,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { if (status.kind === 'ready') { const primedUrl = await primeDocumentPreviewCache( doc.id, - Number(doc.lastModified), + status.previewVersion || Number(doc.lastModified), previewKey, { signal: controller.signal }, ).catch(() => null); diff --git a/src/components/documents/DexieMigrationModal.tsx b/src/components/documents/DexieMigrationModal.tsx deleted file mode 100644 index 2efa278..0000000 --- a/src/components/documents/DexieMigrationModal.tsx +++ /dev/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(''); - - const closeDisabled = isUploading; - - useEffect(() => { - setDisplayMissingCount(missingCount); - }, [missingCount, isOpen]); - - const loadLocalDexieDocs = useCallback(async (): Promise<{ - docs: BaseDocument[]; - pdfById: Map; - epubById: Map; - htmlById: Map; - }> => { - 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 ( - { - if (!closeDisabled) onComplete(); - }} - panelTestId="migration-modal" - className="z-[80]" - > - {title} -
    -

    - 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. -

    - {isUploading && ( -
    -

    {status}

    -
    -
    -
    -
    - )} - {!isUploading && status ?

    {status}

    : null} -
    - -
    - - -
    - - ); -} diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index a397c0f..835deeb 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -1,27 +1,18 @@ 'use client'; -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; -import { useLiveQuery } from 'dexie-react-hooks'; -import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie'; -import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; -import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog'; +import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues } from '@/types/config'; import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; -import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state'; -import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { useAuthSession } from '@/hooks/useAuthSession'; -import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; -import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences'; +import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { applyConfigUpdate } from '@/lib/client/config/updates'; import { useSharedProviders } from '@/hooks/useSharedProviders'; -import toast from 'react-hot-toast'; +import { useUserPreferences } from '@/hooks/useUserPreferences'; +import type { SyncedPreferencesPatch } from '@/types/user-state'; + export type { ViewType } from '@/types/config'; -/** Configuration values for the application */ - -/** Interface defining the configuration context shape and functionality */ interface ConfigContextType { - apiKey: string; - baseUrl: string; viewType: ViewType; voiceSpeed: number; audioPlayerSpeed: number; @@ -40,7 +31,6 @@ interface ConfigContextType { ttsModel: string; ttsInstructions: string; savedVoices: SavedVoices; - updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise; updateConfigKey: (key: K, value: AppConfigValues[K]) => Promise; isLoading: boolean; isDBReady: boolean; @@ -54,456 +44,82 @@ interface ConfigContextType { const ConfigContext = createContext(undefined); -/** - * Provider component for application configuration - * Manages global configuration state and persistence - * @param {Object} props - Component props - * @param {ReactNode} props.children - Child components to be wrapped by the provider - */ export function ConfigProvider({ children }: { children: ReactNode }) { - const [isLoading, setIsLoading] = useState(true); - const [isDBReady, setIsDBReady] = useState(false); - const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab'); - const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys'); - const showAllProviderModels = useFeatureFlag('showAllProviderModels'); - const didRunStartupMigrations = useRef(false); - const didAttemptInitialPreferenceSeedForSession = useRef(null); - const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []); + const { data: session, isPending: isSessionPending } = useAuthSession(); + const sessionId = session?.user?.id ?? 'no-session'; const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders(); - const { data: sessionData, isPending: isSessionPending } = useAuthSession(); - const sessionKey = sessionData?.user?.id ?? 'no-session'; - // The instance/admin default provider. An empty user providerRef "inherits" - // this, resolved (admin slug -> concrete provider) where the value is used. const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider; + const { query, mutation } = useUserPreferences(sessionId, !isSessionPending); - const queueSyncedPreferencePatch = useCallback((patch: Partial) => { - if (sessionKey === 'no-session') return; + const config = useMemo(() => ({ + ...APP_CONFIG_DEFAULTS, + ...(query.data?.preferences ?? {}), + }), [query.data?.preferences]); - const syncedPatch: SyncedPreferencesPatch = {}; - for (const key of SYNCED_PREFERENCE_KEYS) { - if (!(key in patch)) continue; - const value = patch[key]; - if (value === undefined) continue; - (syncedPatch as Record)[key] = value; - } - if (Object.keys(syncedPatch).length === 0) return; - scheduleUserPreferencesSync(syncedPatch, sessionKey); - }, [sessionKey]); + const effectiveProvider = useMemo(() => resolveProviderDefaults({ + providerRef: config.providerRef, + providerType: config.providerType, + sharedProviders, + fallbackProviderRef: adminDefaultProviderRef, + }), [adminDefaultProviderRef, config.providerRef, config.providerType, sharedProviders]); - // 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); - } 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 = {}; - - // 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 = {}; - - if (appConfig.apiKey !== '') resetPatch.apiKey = ''; - if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; - // Built-in providers aren't selectable in restricted mode. Clear any stale - // built-in selection (including the old 'custom-openai' default that used to - // be baked into every config) back to "inherit the admin default" so the - // user follows whatever shared provider the admin has configured. - if (isBuiltInTtsProviderId(appConfig.providerRef)) { - resetPatch.providerRef = ''; - resetPatch.providerType = 'unknown'; - resetPatch.ttsModel = ''; - resetPatch.ttsInstructions = ''; - } - - if (Object.keys(resetPatch).length === 0) return; - - updateAppConfig(resetPatch).catch((error) => { - console.warn('Failed to enforce restricted user API key mode:', error); - }); - queueSyncedPreferencePatch(resetPatch); - } - }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); - - useEffect(() => { - if (showAllProviderModels || !isDBReady || !appConfig || sharedProvidersLoading) return; - // Inheriting (empty providerRef): the effective model is resolved at read - // time, so there is nothing to persist/enforce here. - if (!appConfig.providerRef) return; - const providerDefaults = resolveProviderDefaults({ - providerRef: appConfig.providerRef, - providerType: appConfig.providerType, - sharedProviders, - fallbackProviderRef: adminDefaultProviderRef, - }); - if (!providerDefaults.defaultModel) return; - if (appConfig.ttsModel === providerDefaults.defaultModel) return; - const patch: Partial = { 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 providerType = effectiveProvider.providerType; - const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel; - const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions; + const effectiveTtsModel = config.ttsModel || effectiveProvider.defaultModel; + const effectiveTtsInstructions = config.ttsInstructions || effectiveProvider.defaultInstructions; - useEffect(() => { - if (!isDBReady || !appConfig || sharedProvidersLoading) return; - // Only persist a resolved providerType for an explicitly chosen provider. - // While inheriting (empty providerRef) the type stays unset in storage. - if (!appConfig.providerRef) return; - if (appConfig.providerType === providerType) return; - const patch: Partial = { 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 = { 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 = {}; - if (newConfig.apiKey !== undefined) { - updates.apiKey = newConfig.apiKey; - } - if (newConfig.baseUrl !== undefined) { - updates.baseUrl = newConfig.baseUrl; - } - if (newConfig.viewType !== undefined) { - updates.viewType = newConfig.viewType; - } - await updateAppConfig(updates); - queueSyncedPreferencePatch(updates); - } catch (error) { - console.error('Error updating config:', error); - throw error; - } finally { - setIsLoading(false); - } + const updatePatch = async (patch: Partial) => { + await mutation.mutateAsync(patch as SyncedPreferencesPatch); }; - /** - * Updates a single configuration value by key - * @param {K} key - The configuration key to update - * @param {AppConfigValues[K]} value - The new value for the configuration - */ const updateConfigKey = async (key: K, value: AppConfigValues[K]) => { - try { - setIsLoading(true); - const { storagePatch, syncPatch } = applyConfigUpdate({ - providerRef: effectiveProviderRef, - providerType, - ttsModel: effectiveTtsModel, - savedVoices, - }, key, value); - - await updateAppConfig(storagePatch); - if ( - key === 'voice' || - key === 'providerRef' || - key === 'providerType' || - key === 'ttsModel' || - key === 'savedVoices' || - syncedPreferenceKeys.has(String(key)) - ) { - queueSyncedPreferencePatch(syncPatch); - } - } catch (error) { - console.error(`Error updating config key ${String(key)}:`, error); - throw error; - } finally { - setIsLoading(false); - } + const { syncPatch } = applyConfigUpdate({ + providerRef: effectiveProviderRef, + providerType: effectiveProvider.providerType, + ttsModel: effectiveTtsModel, + savedVoices: config.savedVoices, + }, key, value); + await updatePatch(syncPatch); }; + const isLoading = isSessionPending || query.isPending || mutation.isPending || sharedProvidersLoading; + return ( {children} ); } -/** - * Custom hook to consume the configuration context - * @returns {ConfigContextType} The configuration context value - * @throws {Error} When used outside of ConfigProvider - */ export function useConfig() { const context = useContext(ConfigContext); - if (context === undefined) { - throw new Error('useConfig must be used within a ConfigProvider'); - } + if (!context) throw new Error('useConfig must be used within a ConfigProvider'); return context; } diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index d96dd4d..837d46f 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, useCallback, useContext, useEffect, useMemo, ReactNode } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { BaseDocument } from '@/types/documents'; import { deleteDocuments as deleteServerDocuments, @@ -102,10 +102,31 @@ export function DocumentProvider({ children }: { children: ReactNode }) { return { pdfDocs, epubDocs, htmlDocs }; }, [docs]); + const uploadMutation = useMutation({ + mutationFn: (files: File[]) => uploadServerDocuments(files), + onSuccess: (stored) => { + queryClient.setQueryData(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(documentsQueryKey); + queryClient.setQueryData(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 => { if (files.length === 0) return []; - const stored = await uploadServerDocuments(files); + const stored = await uploadMutation.mutateAsync(files); await Promise.allSettled( stored.map(async (document, index) => { const file = files[index]; @@ -134,20 +155,13 @@ export function DocumentProvider({ children }: { children: ReactNode }) { }), ); - queryClient.setQueryData(documentsQueryKey, (previous) => - mergeStoredDocuments(previous, stored), - ); - return stored; - }, [documentsQueryKey, queryClient]); + }, [uploadMutation]); const deleteDocument = useCallback(async (id: string) => { - await deleteServerDocuments({ ids: [id] }); + await deleteMutation.mutateAsync(id); await evictCachedDocument(id); - queryClient.setQueryData(documentsQueryKey, (previous = []) => - previous.filter((document) => document.id !== id), - ); - }, [documentsQueryKey, queryClient]); + }, [deleteMutation]); return ( (null); -type MigrationPromptState = { - shouldPrompt: boolean; - localCount: number; - missingCount: number; -}; - const EMPTY_CLAIM_COUNTS: ClaimableCounts = { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0, + folders: 0, + onboarding: 0, }; function toClaimableCounts(value: unknown): ClaimableCounts { @@ -41,6 +36,8 @@ function toClaimableCounts(value: unknown): ClaimableCounts { preferences: Number(rec.preferences ?? 0), progress: Number(rec.progress ?? 0), documentSettings: Number(rec.documentSettings ?? 0), + folders: Number(rec.folders ?? 0), + onboarding: Number(rec.onboarding ?? 0), }; } @@ -53,71 +50,22 @@ async function fetchClaimableCounts(): Promise { return toClaimableCounts(data); } -async function getMigrationPromptState(privacyGateSatisfied: boolean): Promise { - 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 { - const appConfig = await getAppConfig(); - const row = appConfig as Record | null; - const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey; - const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey; - - return { - privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false, - firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : false, - }; -} - export function OnboardingFlowProvider({ children }: { children: ReactNode }) { const { data: session, isPending: isSessionPending } = useAuthSession(); const runtimeConfig = useRuntimeConfig(); const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined; const userId = user?.id ?? null; const isAnonymous = Boolean(user?.isAnonymous); - - const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | 'migration' | null>(null); - const [claimableCounts, setClaimableCounts] = useState(EMPTY_CLAIM_COUNTS); - const [migrationCounts, setMigrationCounts] = useState<{ localCount: number; missingCount: number }>({ - localCount: 0, - missingCount: 0, + const claimCountsQuery = useQuery({ + queryKey: queryKeys.claimCounts(userId ?? 'no-session'), + queryFn: fetchClaimableCounts, + enabled: Boolean(userId && !isAnonymous), }); + const refetchClaimCounts = claimCountsQuery.refetch; + + const { query: onboardingQuery } = useOnboardingState(); + const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | null>(null); + const [claimableCounts, setClaimableCounts] = useState(EMPTY_CLAIM_COUNTS); const [changelogOpenSignal, setChangelogOpenSignal] = useState(0); const pendingChangelogOpenRef = useRef(false); @@ -135,9 +83,17 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { ); const runOnceFlow = useCallback(async () => { - const local = await readLocalOnboardingSnapshot(); + // Wait until the onboarding state has actually loaded before deciding whether + // to show the privacy modal. Otherwise the not-yet-loaded query (data === undefined) + // reads as "not accepted", the modal flashes on first paint, then closes once the + // real state arrives. + const onboardingData = onboardingQuery.data; + if (onboardingData === undefined) { + return; + } + const privacyRequired = true; - const privacyAccepted = !privacyRequired || local.privacyAccepted; + const privacyAccepted = !privacyRequired || Boolean(onboardingData.privacyAcceptedAtMs); const isClaimEligible = Boolean( userId @@ -149,26 +105,25 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { let claimHasData = false; if (isClaimEligible) { - claimCounts = await fetchClaimableCounts(); + claimCounts = (await refetchClaimCounts()).data ?? EMPTY_CLAIM_COUNTS; const total = claimCounts.documents + claimCounts.audiobooks + claimCounts.preferences + claimCounts.progress - + claimCounts.documentSettings; + + claimCounts.documentSettings + + claimCounts.folders + + claimCounts.onboarding; claimHasData = total > 0; if (!claimHasData && userId) { claimDismissedUsersRef.current.add(userId); } } - const migrationState = await getMigrationPromptState(privacyAccepted); - const nextStep = resolveNextOnboardingStep({ privacyRequired, privacyAccepted, claimEligible: isClaimEligible, claimHasData, - migrationRequired: migrationState.shouldPrompt, changelogPending: pendingChangelogOpenRef.current, }); @@ -183,26 +138,13 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { return; } - if (nextStep === 'migration') { - setMigrationCounts({ - localCount: migrationState.localCount, - missingCount: migrationState.missingCount, - }); - setActiveBlockingModal('migration'); - return; - } - setActiveBlockingModal(null); - if (!local.firstVisitSettingsOpened) { - await setFirstVisit(true); - } - if (nextStep === 'changelog') { pendingChangelogOpenRef.current = false; setChangelogOpenSignal((value) => value + 1); } - }, [isAnonymous, userId]); + }, [isAnonymous, onboardingQuery.data, refetchClaimCounts, userId]); runOnceFlowRef.current = runOnceFlow; @@ -214,11 +156,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { void runFlow(); }, [runFlow, userId]); - const handleMigrationComplete = useCallback(() => { - setActiveBlockingModal(null); - void runFlow(); - }, [runFlow]); - const handlePrivacyAccepted = useCallback(() => { setActiveBlockingModal(null); void runFlow(); @@ -226,7 +163,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { useEffect(() => { void runFlow(); - }, [isAnonymous, runFlow, userId]); + }, [isAnonymous, onboardingQuery.data, runFlow, userId]); useEffect(() => { const onPrivacyAccepted = () => { @@ -273,12 +210,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { onDismiss={handleClaimComplete} onClaimed={handleClaimComplete} /> - ); } diff --git a/src/contexts/RuntimeConfigContext.tsx b/src/contexts/RuntimeConfigContext.tsx index 30a8e3d..d99203a 100644 --- a/src/contexts/RuntimeConfigContext.tsx +++ b/src/contexts/RuntimeConfigContext.tsx @@ -94,7 +94,7 @@ export function useFeatureFlag(key: K): RuntimeCo /** * Synchronous accessor for modules that are loaded before the React tree - * mounts (e.g. Dexie initialization, config defaults). Falls back to the + * mounts (for example config defaults). Falls back to the * built-in defaults during SSR. */ export function readRuntimeConfigSync(): RuntimeConfig { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index e8431f4..a335fcf 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -33,8 +33,7 @@ import { useConfig } from '@/contexts/ConfigContext'; import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; -import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/client/dexie'; -import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/api/user-state'; +import { useDocumentProgress } from '@/hooks/useDocumentProgress'; import { withRetry, ensureTtsSegments } from '@/lib/client/api/audiobooks'; import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; import { @@ -82,6 +81,7 @@ import type { TTSSegmentManifestItem, } from '@/types/client'; import { isStableEpubLocator } from '@/types/client'; +import { clearCachedAudioObjectUrls, getCachedAudioUrl } from '@/lib/client/cache/audio'; /** * Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub', @@ -420,8 +420,6 @@ const TTSContext = createContext(undefined); export function TTSProvider({ children }: { children: ReactNode }): ReactElement { // Configuration context consumption const { - apiKey: openApiKey, - baseUrl: openApiBaseUrl, isLoading: configIsLoading, voiceSpeed, audioPlayerSpeed, @@ -444,8 +442,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Audio and voice management hooks const audioContext = useAudioContext(); const { availableVoices, fetchVoices } = useVoiceManagement( - openApiKey, - openApiBaseUrl, configProviderRef, configProviderType, configTTSModel, @@ -466,6 +462,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (Array.isArray(id)) return id[0]; return ''; }, [id]); + const { query: progressQuery, schedule: scheduleProgress } = useDocumentProgress(documentId || undefined); const currentReaderType: ReaderType = useMemo(() => { if (pathname.startsWith('/epub/')) return 'epub'; @@ -729,6 +726,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement useEffect(() => () => { clearWarmAudioCache(); + clearCachedAudioObjectUrls(); }, [clearWarmAudioCache]); const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => { @@ -1619,7 +1617,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setTTSModel(configTTSModel); setTTSInstructions(configTTSInstructions); } - }, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]); + }, [configIsLoading, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]); const preloadGenerationSignatureRef = useRef(''); useEffect(() => { @@ -1766,12 +1764,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const reqHeaders: TTSRequestHeaders = { 'Content-Type': 'application/json', - 'x-openai-key': openApiKey || '', 'x-tts-provider': configProviderRef, }; - if (openApiBaseUrl) { - reqHeaders['x-openai-base-url'] = openApiBaseUrl; - } const retryOptions: TTSRetryOptions = { maxRetries: 2, @@ -1914,8 +1908,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ttsModel, ttsInstructions, resolvedLanguage, - openApiKey, - openApiBaseUrl, configProviderRef, configProviderType, providerModelPolicy.supportsInstructions, @@ -2044,7 +2036,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setCurrentSentenceAlignment(playbackSource.manifest.alignment); setCurrentWordIndex(null); } - const audioUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl; + const sourceUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl; + const audioUrl = await getCachedAudioUrl({ + audioKey: playbackSource.manifest.segmentId, + version: playbackSource.manifest.durationMs, + primaryUrl: sourceUrl, + fallbackUrl: playbackSource.fallbackUrl, + }).catch(() => sourceUrl); // Force unload any previous Howl instance to free up resources if (activeHowl) { @@ -2610,12 +2608,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement activeAbortControllers.current.add(controller); const reqHeaders: TTSRequestHeaders = { 'Content-Type': 'application/json', - 'x-openai-key': openApiKey || '', 'x-tts-provider': configProviderRef, }; - if (openApiBaseUrl) { - reqHeaders['x-openai-base-url'] = openApiBaseUrl; - } const retryOptions: TTSRetryOptions = { maxRetries: 2, initialDelay: 300, @@ -2903,12 +2897,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement activeAbortControllers.current.add(controller); const reqHeaders: TTSRequestHeaders = { 'Content-Type': 'application/json', - 'x-openai-key': openApiKey || '', 'x-tts-provider': configProviderRef, }; - if (openApiBaseUrl) { - reqHeaders['x-openai-base-url'] = openApiBaseUrl; - } const retryOptions: TTSRetryOptions = { maxRetries: 2, @@ -3026,8 +3016,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement configProviderRef, configProviderType, ttsModel, - openApiKey, - openApiBaseUrl, providerModelPolicy.supportsInstructions, ttsInstructions, resolvedLanguage, @@ -3397,13 +3385,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement skipBackward, }); - // Load last location on mount for EPUB/PDF/HTML. - // Prefer server-backed progress when available, then fall back to local Dexie. + // Load the server-backed last location for EPUB/PDF/HTML. useEffect(() => { - if (!id) return; - - let cancelled = false; - const docId = id as string; + if (!id || !progressQuery.data?.location) return; const applyLocation = (lastLocation: string) => { if (isEPUB && locationChangeHandlerRef.current) { @@ -3454,35 +3438,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }; - const load = async () => { - try { - const local = await getLastDocumentLocation(docId); - if (!cancelled && local) { - applyLocation(local); - } - } catch (error) { - console.warn('Error loading local last location:', error); - } - - try { - const remote = await getDocumentProgress(docId); - if (!cancelled && remote?.location) { - await setLastDocumentLocation(docId, remote.location).catch((error) => { - console.warn('Error caching remote location locally:', error); - }); - applyLocation(remote.location); - } - } catch (error) { - console.warn('Error loading remote progress:', error); - } - }; - - load(); - - return () => { - cancelled = true; - }; - }, [id, isEPUB, currentReaderType]); + applyLocation(progressQuery.data.location); + }, [id, isEPUB, currentReaderType, progressQuery.data?.location]); // Save current position periodically for non-EPUB readers. useEffect(() => { @@ -3491,10 +3448,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ? `html:${encodeURIComponent(String(currDocPage || 1))}:${currentIndex}` : `${currDocPageNumber}:${currentIndex}`; const timeoutId = setTimeout(() => { - setLastDocumentLocation(id as string, location).catch(error => { - console.warn('Error saving non-EPUB location:', error); - }); - scheduleDocumentProgressSync({ + scheduleProgress({ documentId: id as string, readerType: currentReaderType, location, @@ -3503,7 +3457,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement return () => clearTimeout(timeoutId); } - }, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType]); + }, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType, scheduleProgress]); /** * Renders the TTS context provider with its children diff --git a/src/hooks/audio/useVoiceManagement.ts b/src/hooks/audio/useVoiceManagement.ts index b7c2745..de2e20f 100644 --- a/src/hooks/audio/useVoiceManagement.ts +++ b/src/hooks/audio/useVoiceManagement.ts @@ -7,16 +7,12 @@ import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy' /** * Custom hook for managing TTS voices - * @param apiKey OpenAI API key - * @param baseUrl OpenAI API base URL * @param providerRef TTS provider routing reference (built-in id or shared slug) * @param providerType Resolved provider type for capability/default logic * @param ttsModel TTS model name * @returns Object containing available voices and fetch function */ export function useVoiceManagement( - apiKey: string | undefined, - baseUrl: string | undefined, providerRef: string | undefined, providerType: TtsProviderType | undefined, ttsModel: string | undefined @@ -29,8 +25,6 @@ export function useVoiceManagement( try { console.log('Fetching voices...'); const data = await getVoices({ - 'x-openai-key': apiKey || '', - 'x-openai-base-url': baseUrl || '', 'x-tts-provider': providerRef || 'openai', 'x-tts-model': ttsModel || 'tts-1', 'Content-Type': 'application/json', @@ -56,7 +50,7 @@ export function useVoiceManagement( model: ttsModel || 'tts-1', }).defaultVoices); } - }, [apiKey, baseUrl, providerRef, providerType, ttsModel]); + }, [providerRef, providerType, ttsModel]); return { availableVoices, fetchVoices }; } diff --git a/src/hooks/epub/useEPUBAudiobook.ts b/src/hooks/epub/useEPUBAudiobook.ts index ded81c1..bf93786 100644 --- a/src/hooks/epub/useEPUBAudiobook.ts +++ b/src/hooks/epub/useEPUBAudiobook.ts @@ -35,8 +35,6 @@ export function filterNonEmptySpineTextEntries(entri type UseEpubAudiobookParams = { bookRef: RefObject; tocRef: RefObject; - apiKey: string; - baseUrl: string; providerRef: string; }; @@ -61,8 +59,6 @@ type UseEpubAudiobookResult = { export function useEPUBAudiobook({ bookRef, tocRef, - apiKey, - baseUrl, providerRef, }: UseEpubAudiobookParams): UseEpubAudiobookResult { const loadSpineSection = useCallback(async (href: string) => { @@ -131,8 +127,6 @@ export function useEPUBAudiobook({ try { return await runAudiobookGeneration({ adapter: audiobookAdapter, - apiKey, - baseUrl, defaultProvider: providerRef, onProgress, signal, @@ -145,7 +139,7 @@ export function useEPUBAudiobook({ console.error('Error creating audiobook:', error); throw error; } - }, [audiobookAdapter, apiKey, baseUrl, providerRef]); + }, [audiobookAdapter, providerRef]); const regenerateChapter = useCallback(async ( chapterIndex: number, @@ -161,8 +155,6 @@ export function useEPUBAudiobook({ bookId, format, signal, - apiKey, - baseUrl, defaultProvider: providerRef, settings, }); @@ -173,7 +165,7 @@ export function useEPUBAudiobook({ console.error('Error regenerating chapter:', error); throw error; } - }, [audiobookAdapter, apiKey, baseUrl, providerRef]); + }, [audiobookAdapter, providerRef]); return { createFullAudioBook, diff --git a/src/hooks/epub/useEPUBDocuments.ts b/src/hooks/epub/useEPUBDocuments.ts deleted file mode 100644 index 989d009..0000000 --- a/src/hooks/epub/useEPUBDocuments.ts +++ /dev/null @@ -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 => { - 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 => { - await db['epub-documents'].delete(id); - }, []); - - const clearDocuments = useCallback(async (): Promise => { - await db['epub-documents'].clear(); - }, []); - - return { - documents: documents ?? [], - isLoading, - addDocument, - removeDocument, - clearDocuments, - }; -} diff --git a/src/hooks/epub/useEPUBLocationController.ts b/src/hooks/epub/useEPUBLocationController.ts index 726122a..f20374a 100644 --- a/src/hooks/epub/useEPUBLocationController.ts +++ b/src/hooks/epub/useEPUBLocationController.ts @@ -3,33 +3,14 @@ import { useCallback, type MutableRefObject, type RefObject } from 'react'; import type { Book, Rendition } from 'epubjs'; -import { setLastDocumentLocation } from '@/lib/client/dexie'; -import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state'; +import { useDocumentProgress } from '@/hooks/useDocumentProgress'; -type EpubLocation = string | number; - -export function isDirectionalEpubLocation(location: EpubLocation): location is 'next' | 'prev' { - return location === 'next' || location === 'prev'; -} - -export function shouldNavigateToDifferentCfi( - location: EpubLocation, - currentStartCfi: string | undefined, -): location is string { - return ( - typeof location === 'string' - && !isDirectionalEpubLocation(location) - && !!currentStartCfi - && location !== currentStartCfi - ); -} - -export function shouldPersistEpubLocation( - documentId: string | undefined, - previousLocation: EpubLocation, -): documentId is string { - return typeof documentId === 'string' && documentId.length > 0 && previousLocation !== 1; -} +import { + isDirectionalEpubLocation, + shouldNavigateToDifferentCfi, + shouldPersistEpubLocation, + type EpubLocation, +} from '@/lib/client/epub/location-controller'; type UseEpubLocationControllerParams = { documentId?: string; @@ -54,6 +35,7 @@ export function useEPUBLocationController({ renditionRef, locationRef, }: UseEpubLocationControllerParams): (location: EpubLocation) => void { + const { schedule: scheduleProgress } = useDocumentProgress(documentId); const safeRenditionNavigate = useCallback((navigation: 'next' | 'prev' | 'display', location?: string) => { const book = bookRef.current; const rendition = renditionRef.current; @@ -133,10 +115,9 @@ export function useEPUBLocationController({ return; } - // Save the location to IndexedDB if not initial + // Save the server-backed location after the first real rendition update. if (shouldPersistEpubLocation(documentId, locationRef.current)) { - setLastDocumentLocation(documentId, location.toString()); - scheduleDocumentProgressSync({ + scheduleProgress({ documentId, readerType: 'epub', location: location.toString(), @@ -160,8 +141,11 @@ export function useEPUBLocationController({ safeRenditionNavigate, setIsEpub, shouldPauseRef, + scheduleProgress, skipToLocation, ]); return handleLocationChanged; } + +export { isDirectionalEpubLocation, shouldNavigateToDifferentCfi, shouldPersistEpubLocation }; diff --git a/src/hooks/html/useHTMLDocuments.ts b/src/hooks/html/useHTMLDocuments.ts deleted file mode 100644 index 2714c05..0000000 --- a/src/hooks/html/useHTMLDocuments.ts +++ /dev/null @@ -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 => { - 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 => { - await db['html-documents'].delete(id); - }, []); - - const clearDocuments = useCallback(async (): Promise => { - await db['html-documents'].clear(); - }, []); - - return { - documents: documents ?? [], - isLoading, - addDocument, - removeDocument, - clearDocuments, - }; -} diff --git a/src/hooks/pdf/usePDFDocuments.ts b/src/hooks/pdf/usePDFDocuments.ts deleted file mode 100644 index 44f0668..0000000 --- a/src/hooks/pdf/usePDFDocuments.ts +++ /dev/null @@ -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 => { - 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 => { - await db['pdf-documents'].delete(id); - }, []); - - const clearDocuments = useCallback(async (): Promise => { - await db['pdf-documents'].clear(); - }, []); - - return { - documents: documents ?? [], - isLoading, - addDocument, - removeDocument, - clearDocuments, - }; -} diff --git a/src/hooks/useDocumentLanguage.ts b/src/hooks/useDocumentLanguage.ts index c8ca980..3b1d8ed 100644 --- a/src/hooks/useDocumentLanguage.ts +++ b/src/hooks/useDocumentLanguage.ts @@ -1,52 +1,31 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; -import { getDocumentSettings, putDocumentSettings } from '@/lib/client/api/documents'; import { mergeDocumentSettings } from '@/lib/shared/document-settings'; import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings'; +import { useDocumentSettings } from '@/hooks/useDocumentSettings'; export function useDocumentLanguage(documentId: string | undefined): { language: string; updateLanguage: (language: string) => Promise; } { - const [settings, setSettings] = useState(DEFAULT_DOCUMENT_SETTINGS); - - useEffect(() => { - setSettings(DEFAULT_DOCUMENT_SETTINGS); - if (!documentId) return; - - const controller = new AbortController(); - void getDocumentSettings(documentId, { signal: controller.signal }) - .then((response) => { - setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings)); - }) - .catch((error) => { - if (error instanceof DOMException && error.name === 'AbortError') return; - console.warn('Failed to load document language, using automatic detection:', error); - }); - - return () => controller.abort(); - }, [documentId]); + const { query, mutation } = useDocumentSettings(documentId); + const settings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, query.data?.settings); const updateLanguage = useCallback(async (language: string): Promise => { if (!documentId) return; - let next = DEFAULT_DOCUMENT_SETTINGS; - setSettings((prev) => { - next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, { - ...prev, - schemaVersion: 1, - language, - }); - return next; + const next: DocumentSettings = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, { + ...settings, + schemaVersion: 1, + language, }); try { - const response = await putDocumentSettings(documentId, next); - setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings)); + await mutation.mutateAsync(next); } catch (error) { console.warn('Failed to persist document language:', error); } - }, [documentId]); + }, [documentId, mutation, settings]); return { language: settings.language ?? 'auto', diff --git a/src/hooks/useDocumentProgress.ts b/src/hooks/useDocumentProgress.ts new file mode 100644 index 0000000..cd8ad6e --- /dev/null +++ b/src/hooks/useDocumentProgress.ts @@ -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 | 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(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 }; +} diff --git a/src/hooks/useDocumentSettings.ts b/src/hooks/useDocumentSettings.ts new file mode 100644 index 0000000..915c5ce --- /dev/null +++ b/src/hooks/useDocumentSettings.ts @@ -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 }; +} diff --git a/src/hooks/useFolders.ts b/src/hooks/useFolders.ts new file mode 100644 index 0000000..715a9da --- /dev/null +++ b/src/hooks/useFolders.ts @@ -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(url: string, init?: RequestInit): Promise { + 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; +} + +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(foldersKey, (rows = []) => [...rows, folder]); + if (input.documentIds?.length) queryClient.setQueryData(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(documentsKey); + queryClient.setQueryData(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(foldersKey); + const previousDocuments = queryClient.getQueryData(documentsKey); + queryClient.setQueryData(foldersKey, (rows = []) => rows.filter((folder) => folder.id !== id)); + queryClient.setQueryData(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(foldersKey); + const previousDocuments = queryClient.getQueryData(documentsKey); + queryClient.setQueryData(foldersKey, []); + queryClient.setQueryData(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 }; +} diff --git a/src/hooks/useOnboardingState.ts b/src/hooks/useOnboardingState.ts new file mode 100644 index 0000000..f5bc1a0 --- /dev/null +++ b/src/hooks/useOnboardingState.ts @@ -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 { + 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(key); + queryClient.setQueryData(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 }; +} diff --git a/src/hooks/useUserPreferences.ts b/src/hooks/useUserPreferences.ts new file mode 100644 index 0000000..6b8bb4b --- /dev/null +++ b/src/hooks/useUserPreferences.ts @@ -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>>(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 }; +} diff --git a/src/lib/client/api/documents.ts b/src/lib/client/api/documents.ts index 8d05a09..8bc3703 100644 --- a/src/lib/client/api/documents.ts +++ b/src/lib/client/api/documents.ts @@ -85,7 +85,11 @@ export async function listDocuments(options?: { ids?: string[]; signal?: AbortSi export async function getDocumentMetadata(id: string, options?: { signal?: AbortSignal }): Promise { const docs = await listDocuments({ ids: [id], signal: options?.signal }); - return docs[0] ?? null; + const document = docs[0] ?? null; + if (document) { + void fetch(`/api/documents/${encodeURIComponent(id)}/opened`, { method: 'PUT' }).catch(() => {}); + } + return document; } export class ParsedPdfNotReadyError extends Error { @@ -439,9 +443,13 @@ export async function deleteDocuments(options?: { ids?: string[]; signal?: Abort } export async function downloadDocumentContent(id: string, options?: { signal?: AbortSignal }): Promise { + return (await fetchDocumentContentResponse(id, options)).arrayBuffer(); +} + +export async function fetchDocumentContentResponse(id: string, options?: { signal?: AbortSignal }): Promise { const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(id)}`; - const fetchFallback = async (): Promise => { + const fetchFallback = async (): Promise => { const res = await fetch(fallbackUrl, { signal: options?.signal }); if (!res.ok) { const contentType = res.headers.get('content-type') || ''; @@ -451,7 +459,7 @@ export async function downloadDocumentContent(id: string, options?: { signal?: A } throw new Error(`Failed to download document (status ${res.status})`); } - return res.arrayBuffer(); + return res; }; try { @@ -467,7 +475,7 @@ export async function downloadDocumentContent(id: string, options?: { signal?: A } throw new Error(`Failed to download document (status ${directRes.status})`); } - return directRes.arrayBuffer(); + return directRes; } catch (error) { if (options?.signal?.aborted) throw error; return fetchFallback(); @@ -508,6 +516,7 @@ export type DocumentPreviewReady = { fallbackUrl: string; presignUrl: string; directUrl?: string; + previewVersion: string; }; export type DocumentPreviewStatus = DocumentPreviewPending | DocumentPreviewReady; @@ -556,12 +565,14 @@ export async function getDocumentPreviewStatus( fallbackUrl?: string; presignUrl?: string; directUrl?: string; + previewVersion?: string; } | null; return { kind: 'ready', fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id), presignUrl: data?.presignUrl || documentPreviewPresignUrl(id), directUrl: data?.directUrl, + previewVersion: data?.previewVersion || '', }; } @@ -606,4 +617,3 @@ export async function importUrl( return (await res.json()) as { title: string; content: string }; } - diff --git a/src/lib/client/api/user-state.ts b/src/lib/client/api/user-state.ts index d9e8cf1..27ea086 100644 --- a/src/lib/client/api/user-state.ts +++ b/src/lib/client/api/user-state.ts @@ -78,102 +78,6 @@ export async function postChangelogVersionCheck( return (await res.json()) as ChangelogVersionCheckResponse; } -type PendingPreferenceSync = { - patch: SyncedPreferencesPatch; - timer: ReturnType | 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 { - if (pendingPreferenceSync.timer) { - clearTimeout(pendingPreferenceSync.timer); - pendingPreferenceSync.timer = null; - } - - const payload = { ...pendingPreferenceSync.patch }; - pendingPreferenceSync.patch = {}; - if (Object.keys(payload).length === 0) return; - - if (activeSyncController) activeSyncController.abort(); - activeSyncController = new AbortController(); - - try { - await putUserPreferences(payload, { signal: activeSyncController.signal }); - } finally { - activeSyncController = null; - } -} - export async function getDocumentProgress( documentId: string, options?: { signal?: AbortSignal }, @@ -216,56 +120,3 @@ export async function putDocumentProgress(payload: { const data = (await res.json()) as ProgressResponse; return data.progress ?? null; } - -type PendingProgressSync = { - payload: { - documentId: string; - readerType: ReaderType; - location: string; - progress: number | null; - }; - timer: ReturnType | null; -}; - -const pendingProgressByDoc = new Map(); - -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); -} diff --git a/src/lib/client/audiobooks/pipeline.ts b/src/lib/client/audiobooks/pipeline.ts index d2e7c4e..6e232a8 100644 --- a/src/lib/client/audiobooks/pipeline.ts +++ b/src/lib/client/audiobooks/pipeline.ts @@ -29,8 +29,6 @@ export interface AudiobookSourceAdapter { interface RunAudiobookGenerationOptions { adapter: AudiobookSourceAdapter; - apiKey: string; - baseUrl: string; defaultProvider: string; onProgress: (progress: number) => void; signal?: AbortSignal; @@ -47,8 +45,6 @@ interface RegenerateAudiobookChapterOptions { bookId: string; format: TTSAudiobookFormat; signal: AbortSignal; - apiKey: string; - baseUrl: string; defaultProvider: string; settings?: AudiobookGenerationSettings; retryOptions?: TTSRetryOptions; @@ -71,14 +67,10 @@ function resolveAudiobookRequestSettings( } function buildAudiobookRequestHeaders( - apiKey: string, - baseUrl: string, effectiveProvider: string, ): TTSRequestHeaders { return { 'Content-Type': 'application/json', - 'x-openai-key': apiKey, - 'x-openai-base-url': baseUrl, 'x-tts-provider': effectiveProvider, }; } @@ -95,8 +87,6 @@ function createAudiobookAbortError(): Error { export async function runAudiobookGeneration({ adapter, - apiKey, - baseUrl, defaultProvider, onProgress, signal, @@ -120,7 +110,7 @@ export async function runAudiobookGeneration({ } const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format); - const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef); + const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef); let processedLength = 0; let bookId = providedBookId; @@ -221,8 +211,6 @@ export async function regenerateAudiobookChapter({ bookId, format, signal, - apiKey, - baseUrl, defaultProvider, settings, retryOptions = { @@ -238,7 +226,7 @@ export async function regenerateAudiobookChapter({ } const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format); - const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef); + const reqHeaders = buildAudiobookRequestHeaders(effectiveProviderRef); return withRetry( async () => { diff --git a/src/lib/client/cache/audio.ts b/src/lib/client/cache/audio.ts new file mode 100644 index 0000000..34c4d7e --- /dev/null +++ b/src/lib/client/cache/audio.ts @@ -0,0 +1,28 @@ +import { audioBlobCacheKey, getCachedBlob } from '@/lib/client/cache/blob-cache'; + +const objectUrls = new Map(); + +export async function getCachedAudioUrl(input: { + audioKey: string; + version: string | number; + primaryUrl: string | null; + fallbackUrl: string | null; +}): Promise { + 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(); +} diff --git a/src/lib/client/cache/blob-cache.ts b/src/lib/client/cache/blob-cache.ts new file mode 100644 index 0000000..452e33c --- /dev/null +++ b/src/lib/client/cache/blob-cache.ts @@ -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, +): Promise { + 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 { + 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 { + 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 { + 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))}`; +} diff --git a/src/lib/client/cache/documents.ts b/src/lib/client/cache/documents.ts index ef3aa06..81aaf47 100644 --- a/src/lib/client/cache/documents.ts +++ b/src/lib/client/cache/documents.ts @@ -1,152 +1,50 @@ import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '@/types/documents'; -import { downloadDocumentContent } from '@/lib/client/api/documents'; +import { fetchDocumentContentResponse } from '@/lib/client/api/documents'; +import { + clearBlobCache, + documentBlobCacheKey, + evictCachedBlobPrefix, + getCachedBlob, + putCachedBlob, +} from '@/lib/client/cache/blob-cache'; -export type DocumentCacheBackend = { - get: (meta: BaseDocument) => Promise; - putPdf: (meta: BaseDocument, data: ArrayBuffer) => Promise; - putEpub: (meta: BaseDocument, data: ArrayBuffer) => Promise; - putHtml: (meta: BaseDocument, data: string) => Promise; - download: (id: string, options?: { signal?: AbortSignal }) => Promise; - decodeText: (buffer: ArrayBuffer) => string; -}; +function stableKey(meta: BaseDocument): string { + return documentBlobCacheKey(meta.id, meta.contentVersion || meta.id); +} -export async function ensureCachedDocumentCore( +async function readDocument(meta: BaseDocument, response: Response): Promise { + if (meta.type === 'html') { + return { ...meta, type: 'html', data: await response.text() }; + } + const data = await response.arrayBuffer(); + if (meta.type === 'epub') return { ...meta, type: 'epub', data }; + return { ...meta, type: 'pdf', data }; +} + +export async function ensureCachedDocument( meta: BaseDocument, - backend: DocumentCacheBackend, options?: { signal?: AbortSignal }, ): Promise { - const cached = await backend.get(meta); - if (cached) return cached; - - const buffer = await backend.download(meta.id, { signal: options?.signal }); - - if (meta.type === 'pdf') { - await backend.putPdf(meta, buffer); - const after = await backend.get(meta); - if (!after || after.type !== 'pdf') throw new Error('Failed to cache PDF'); - return after; + if (meta.type !== 'pdf' && meta.type !== 'epub' && meta.type !== 'html') { + throw new Error(`Unsupported cached document type: ${meta.type}`); } - - if (meta.type === 'epub') { - await backend.putEpub(meta, buffer); - const after = await backend.get(meta); - if (!after || after.type !== 'epub') throw new Error('Failed to cache EPUB'); - return after; - } - - const decoded = backend.decodeText(buffer); - await backend.putHtml(meta, decoded); - const after = await backend.get(meta); - if (!after || after.type !== 'html') throw new Error('Failed to cache HTML'); - return after; -} - -export async function getCachedPdf(id: string): Promise { - const { getPdfDocument } = await import('@/lib/client/dexie'); - return (await getPdfDocument(id)) ?? null; -} - -export async function putCachedPdf(meta: BaseDocument, data: ArrayBuffer): Promise { - 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 { - const { removePdfDocument } = await import('@/lib/client/dexie'); - await removePdfDocument(id); -} - -export async function getCachedEpub(id: string): Promise { - const { getEpubDocument } = await import('@/lib/client/dexie'); - return (await getEpubDocument(id)) ?? null; -} - -export async function putCachedEpub(meta: BaseDocument, data: ArrayBuffer): Promise { - 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 { - const { removeEpubDocument } = await import('@/lib/client/dexie'); - await removeEpubDocument(id); -} - -export async function getCachedHtml(id: string): Promise { - const { getHtmlDocument } = await import('@/lib/client/dexie'); - return (await getHtmlDocument(id)) ?? null; -} - -export async function putCachedHtml(meta: BaseDocument, data: string): Promise { - 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 { - const { removeHtmlDocument } = await import('@/lib/client/dexie'); - await removeHtmlDocument(id); -} - -export async function evictCachedDocument(id: string): Promise { - await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]); -} - -export async function clearDocumentCache(): Promise { - const { clearPdfDocuments, clearEpubDocuments, clearHtmlDocuments } = await import('@/lib/client/dexie'); - await Promise.all([clearPdfDocuments(), clearEpubDocuments(), clearHtmlDocuments()]); + const response = await getCachedBlob(stableKey(meta), () => fetchDocumentContentResponse(meta.id, options)); + return readDocument(meta, response); } export async function cacheStoredDocumentFromBytes(stored: BaseDocument, bytes: ArrayBuffer): Promise { - if (stored.type === 'pdf') { - await putCachedPdf(stored, bytes); - return; - } - if (stored.type === 'epub') { - await putCachedEpub(stored, bytes); - return; - } - if (stored.type === 'html') { - const decoded = new TextDecoder().decode(new Uint8Array(bytes)); - await putCachedHtml(stored, decoded); - } + const type = stored.type === 'pdf' + ? 'application/pdf' + : stored.type === 'epub' + ? 'application/epub+zip' + : 'text/plain; charset=utf-8'; + await putCachedBlob(stableKey(stored), new Response(bytes, { status: 200, headers: { 'Content-Type': type } })); } -export async function ensureCachedDocument(meta: BaseDocument, options?: { signal?: AbortSignal }): Promise { - return ensureCachedDocumentCore( - meta, - { - get: async (m) => { - const { getPdfDocument, getEpubDocument, getHtmlDocument } = await import('@/lib/client/dexie'); - if (m.type === 'pdf') return (await getPdfDocument(m.id)) ?? null; - if (m.type === 'epub') return (await getEpubDocument(m.id)) ?? null; - return (await getHtmlDocument(m.id)) ?? null; - }, - putPdf: putCachedPdf, - putEpub: putCachedEpub, - putHtml: putCachedHtml, - download: downloadDocumentContent, - decodeText: (buffer) => new TextDecoder().decode(new Uint8Array(buffer)), - }, - options, - ); +export async function evictCachedDocument(id: string): Promise { + await evictCachedBlobPrefix(`/openreader-cache/documents/${encodeURIComponent(id)}/`); +} + +export async function clearDocumentCache(): Promise { + await clearBlobCache(); } diff --git a/src/lib/client/cache/previews.ts b/src/lib/client/cache/previews.ts index 9fb067f..3bc85d9 100644 --- a/src/lib/client/cache/previews.ts +++ b/src/lib/client/cache/previews.ts @@ -1,24 +1,11 @@ -import { - clearDocumentPreviewCache as clearPersistedDocumentPreviewCache, - getDocumentPreviewCache, - putDocumentPreviewCache, - removeDocumentPreviewCache, -} from '@/lib/client/dexie'; import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/client/api/documents'; +import { clearBlobCache, getCachedBlob, previewBlobCacheKey } from '@/lib/client/cache/blob-cache'; const inMemoryPreviewUrlCache = new Map(); const inFlightPreviewPrime = new Map>(); -const inFlightPersistedPreviewUrl = new Map>(); -const PREVIEW_CACHE_SCHEMA_VERSION = 4; function revokeIfBlobUrl(url: string | null | undefined): void { - if (!url) return; - if (!url.startsWith('blob:')) return; - try { - URL.revokeObjectURL(url); - } catch { - // ignore - } + if (url?.startsWith('blob:')) URL.revokeObjectURL(url); } export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null { @@ -27,131 +14,62 @@ export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null { export function setInMemoryDocumentPreviewUrl(cacheKey: string, url: string): void { const prev = inMemoryPreviewUrlCache.get(cacheKey); - if (prev && prev !== url) { - revokeIfBlobUrl(prev); - } + if (prev && prev !== url) revokeIfBlobUrl(prev); inMemoryPreviewUrlCache.set(cacheKey, url); } export function clearInMemoryDocumentPreviewCache(): void { - for (const value of inMemoryPreviewUrlCache.values()) { - revokeIfBlobUrl(value); - } + for (const value of inMemoryPreviewUrlCache.values()) revokeIfBlobUrl(value); inMemoryPreviewUrlCache.clear(); - inFlightPersistedPreviewUrl.clear(); +} + +async function fetchPreviewSource(docId: string, signal?: AbortSignal): Promise { + const options = { signal, cache: 'no-store' as const }; + const direct = await fetch(documentPreviewPresignUrl(docId), options).catch(() => null); + if (direct?.ok) return direct; + return fetch(documentPreviewFallbackUrl(docId), options); } export async function getPersistedDocumentPreviewUrl( docId: string, - lastModified: number, + previewVersion: string | number, cacheKey: string, ): Promise { - const cachedUrl = getInMemoryDocumentPreviewUrl(cacheKey); - if (cachedUrl) return cachedUrl; - - const persistedKey = `${cacheKey}:${Number(lastModified)}`; - const existing = inFlightPersistedPreviewUrl.get(persistedKey); - if (existing) { - return existing; - } - - const promise = (async (): Promise => { - const row = await getDocumentPreviewCache(docId); - if (!row) return null; - - if (Number((row as { previewVersion?: number }).previewVersion ?? 1) !== PREVIEW_CACHE_SCHEMA_VERSION) { - await removeDocumentPreviewCache(docId).catch(() => {}); - return null; - } - - if (Number(row.lastModified) !== Number(lastModified)) { - await removeDocumentPreviewCache(docId).catch(() => {}); - return null; - } - - const latestCachedUrl = getInMemoryDocumentPreviewUrl(cacheKey); - if (latestCachedUrl) return latestCachedUrl; - - const contentType = row.contentType || 'image/jpeg'; - const bytes = row.data; - if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) { - await removeDocumentPreviewCache(docId).catch(() => {}); - return null; - } - - const url = URL.createObjectURL(new Blob([bytes], { type: contentType })); - setInMemoryDocumentPreviewUrl(cacheKey, url); - return url; - })(); - - inFlightPersistedPreviewUrl.set(persistedKey, promise); - try { - return await promise; - } finally { - if (inFlightPersistedPreviewUrl.get(persistedKey) === promise) { - inFlightPersistedPreviewUrl.delete(persistedKey); - } - } + return primeDocumentPreviewCache(docId, previewVersion, cacheKey); } export async function primeDocumentPreviewCache( docId: string, - lastModified: number, + previewVersion: string | number, cacheKey: string, options?: { signal?: AbortSignal }, ): Promise { - const primeKey = `${cacheKey}:${Number(lastModified)}`; - const existingPrime = inFlightPreviewPrime.get(primeKey); - if (existingPrime) { - return existingPrime; - } - - const promise = (async (): Promise => { - const existing = await getPersistedDocumentPreviewUrl(docId, lastModified, cacheKey); + const memory = getInMemoryDocumentPreviewUrl(cacheKey); + if (memory) return memory; + const primeKey = `${docId}:${previewVersion}`; + const existing = inFlightPreviewPrime.get(primeKey); if (existing) return existing; - - const fetchOptions = { - signal: options?.signal, - cache: 'no-store' as const, - }; - - // Prefer presign path for priming so healthy direct object access avoids proxy load. - let res = await fetch(documentPreviewPresignUrl(docId), fetchOptions).catch(() => null); - if (!res || !res.ok) { - res = await fetch(documentPreviewFallbackUrl(docId), fetchOptions).catch(() => null); - } - if (!res || !res.ok) return null; - - const blob = await res.blob(); - const bytes = await blob.arrayBuffer(); - if (bytes.byteLength === 0) return null; - - const contentType = blob.type || 'image/jpeg'; - await putDocumentPreviewCache({ - docId, - lastModified: Number(lastModified), - contentType, - data: bytes, - cachedAt: Date.now(), - previewVersion: PREVIEW_CACHE_SCHEMA_VERSION, - }); - - const url = URL.createObjectURL(new Blob([bytes], { type: contentType })); - setInMemoryDocumentPreviewUrl(cacheKey, url); - return url; + const promise = (async () => { + const response = await getCachedBlob( + previewBlobCacheKey(docId, previewVersion), + () => fetchPreviewSource(docId, options?.signal), + ).catch(() => null); + if (!response?.ok) return null; + const blob = await response.blob(); + if (blob.size === 0) return null; + const url = URL.createObjectURL(blob); + setInMemoryDocumentPreviewUrl(cacheKey, url); + return url; })(); - inFlightPreviewPrime.set(primeKey, promise); try { return await promise; } finally { - if (inFlightPreviewPrime.get(primeKey) === promise) { - inFlightPreviewPrime.delete(primeKey); - } + inFlightPreviewPrime.delete(primeKey); } } export async function clearAllDocumentPreviewCaches(): Promise { clearInMemoryDocumentPreviewCache(); - await clearPersistedDocumentPreviewCache(); + await clearBlobCache(); } diff --git a/src/lib/client/dexie.ts b/src/lib/client/dexie.ts deleted file mode 100644 index 19bc09b..0000000 --- a/src/lib/client/dexie.ts +++ /dev/null @@ -1,1285 +0,0 @@ -import Dexie, { type EntityTable } from 'dexie'; -import { APP_CONFIG_DEFAULTS, getAppConfigDefaults, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config'; -import { - isTtsProviderType, - type TtsProviderType, -} from '@/lib/shared/tts-provider-catalog'; -import { defaultModelForProviderType, normalizeLegacyProviderRef, resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy'; -import { - PDFDocument, - EPUBDocument, - HTMLDocument, - DocumentListState, - BaseDocument, - DocumentListDocument, -} from '@/types/documents'; -import { sha256HexFromBytes, sha256HexFromString } from '@/lib/client/sha256'; -import { downloadDocumentContent, listDocuments, uploadDocumentSources, type UploadSource } from '@/lib/client/api/documents'; -import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents'; - -const DB_NAME = 'openreader-db'; -// Managed via Dexie (version bumped from the original manual IndexedDB) -const DB_VERSION = 9; - -const PDF_TABLE = 'pdf-documents' as const; -const EPUB_TABLE = 'epub-documents' as const; -const HTML_TABLE = 'html-documents' as const; -const CONFIG_TABLE = 'config' as const; -const APP_CONFIG_TABLE = 'app-config' as const; -const LAST_LOCATION_TABLE = 'last-locations' as const; -const DOCUMENT_ID_MAP_TABLE = 'document-id-map' as const; -const PREVIEW_CACHE_TABLE = 'document-preview-cache' as const; -const MIB = 1024 * 1024; -const DOCUMENT_CACHE_MAX_BYTES = 1024 * MIB; // 1 GiB -const PREVIEW_CACHE_MAX_BYTES = 128 * MIB; // 128 MiB - -interface DocumentCacheMeta { - cacheCreatedAt?: number; - cacheAccessedAt?: number; - cacheByteSize?: number; -} - -type PDFCacheRow = PDFDocument & DocumentCacheMeta; -type EPUBCacheRow = EPUBDocument & DocumentCacheMeta; -type HTMLCacheRow = HTMLDocument & DocumentCacheMeta; - -export interface LastLocationRow { - docId: string; - location: string; -} - -export interface DocumentIdMapRow { - oldId: string; - id: string; - createdAt: number; -} - -export interface DocumentPreviewCacheRow { - docId: string; - lastModified: number; - contentType: string; - data: ArrayBuffer; - cachedAt: number; - byteSize?: number; - previewVersion?: number; -} - -export interface ConfigRow { - key: string; - value: string; -} - -type OpenReaderDB = Dexie & { - [PDF_TABLE]: EntityTable; - [EPUB_TABLE]: EntityTable; - [HTML_TABLE]: EntityTable; - [CONFIG_TABLE]: EntityTable; - [APP_CONFIG_TABLE]: EntityTable; - [LAST_LOCATION_TABLE]: EntityTable; - [DOCUMENT_ID_MAP_TABLE]: EntityTable; - [PREVIEW_CACHE_TABLE]: EntityTable; -}; - -export const db = new Dexie(DB_NAME) as OpenReaderDB; - - - -type DexieOpenStatus = 'opening' | 'opened' | 'blocked' | 'stalled' | 'error'; - -function emitDexieStatus(status: DexieOpenStatus, detail?: Record): void { - if (typeof window === 'undefined') return; - try { - window.dispatchEvent( - new CustomEvent('openreader:dexie', { - detail: { status, ...detail }, - }), - ); - } catch { - // ignore - } -} - -if (typeof window !== 'undefined') { - // Fired when this tab's open/upgrade is blocked by another tab holding the DB open. - db.on('blocked', () => { - emitDexieStatus('blocked'); - }); -} - -const PROVIDER_DEFAULT_BASE_URL: Record = { - openai: 'https://api.openai.com/v1', - deepinfra: 'https://api.deepinfra.com/v1/openai', - 'custom-openai': '', -}; - -type RawConfigMap = Record; - -function inferProviderRefAndBaseUrl(raw: RawConfigMap): { providerRef: string; baseUrl: string } { - const cachedApiKey = raw.apiKey; - const cachedBaseUrl = raw.baseUrl; - let inferredProviderRef = raw.providerRef || raw.ttsProvider || raw.provider || ''; - inferredProviderRef = normalizeLegacyProviderRef(inferredProviderRef, getAppConfigDefaults().providerRef); - - if (!raw.providerRef && !raw.ttsProvider && !raw.provider) { - inferredProviderRef = getAppConfigDefaults().providerRef; - } else if (!inferredProviderRef) { - if (cachedBaseUrl) { - const baseUrlLower = cachedBaseUrl.toLowerCase(); - if (baseUrlLower.includes('deepinfra.com')) { - inferredProviderRef = 'deepinfra'; - } else if (baseUrlLower.includes('openai.com')) { - inferredProviderRef = 'openai'; - } else if ( - baseUrlLower.includes('localhost') || - baseUrlLower.includes('127.0.0.1') || - baseUrlLower.includes('internal') - ) { - inferredProviderRef = 'custom-openai'; - } else { - inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai'; - } - } else { - inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai'; - } - } - - let baseUrl = cachedBaseUrl || ''; - if (!baseUrl) { - if (inferredProviderRef === 'openai') { - baseUrl = PROVIDER_DEFAULT_BASE_URL.openai; - } else if (inferredProviderRef === 'deepinfra') { - baseUrl = PROVIDER_DEFAULT_BASE_URL.deepinfra; - } else { - baseUrl = PROVIDER_DEFAULT_BASE_URL['custom-openai']; - } - } - - return { providerRef: inferredProviderRef, baseUrl }; -} - -function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow { - const { providerRef, baseUrl } = inferProviderRefAndBaseUrl(raw); - const providerTypeRaw = raw.providerType; - const providerType: TtsProviderType = - isTtsProviderType(providerTypeRaw) - ? providerTypeRaw - : resolveEffectiveProviderType({ providerRef }); - - let savedVoices: SavedVoices = {}; - if (raw.savedVoices) { - try { - savedVoices = JSON.parse(raw.savedVoices) as SavedVoices; - } catch (error) { - console.error('Error parsing savedVoices during migration:', error); - } - } - - let documentListState: DocumentListState = APP_CONFIG_DEFAULTS.documentListState; - if (raw.documentListState) { - try { - documentListState = JSON.parse(raw.documentListState) as DocumentListState; - } catch (error) { - console.error('Error parsing documentListState during migration:', error); - } - } - - const config: AppConfigRow = { - id: 'singleton', - ...APP_CONFIG_DEFAULTS, - apiKey: raw.apiKey ?? APP_CONFIG_DEFAULTS.apiKey, - baseUrl, - viewType: (raw.viewType as ViewType) || APP_CONFIG_DEFAULTS.viewType, - voiceSpeed: raw.voiceSpeed ? parseFloat(raw.voiceSpeed) : APP_CONFIG_DEFAULTS.voiceSpeed, - audioPlayerSpeed: raw.audioPlayerSpeed ? parseFloat(raw.audioPlayerSpeed) : APP_CONFIG_DEFAULTS.audioPlayerSpeed, - voice: '', - skipBlank: raw.skipBlank === 'false' ? false : APP_CONFIG_DEFAULTS.skipBlank, - epubTheme: raw.epubTheme === 'true', - headerMargin: raw.headerMargin ? parseFloat(raw.headerMargin) : APP_CONFIG_DEFAULTS.headerMargin, - footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin, - leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin, - rightMargin: raw.rightMargin ? parseFloat(raw.rightMargin) : APP_CONFIG_DEFAULTS.rightMargin, - providerRef: providerRef || APP_CONFIG_DEFAULTS.providerRef, - providerType, - ttsModel: raw.ttsModel || (providerType === 'unknown' ? APP_CONFIG_DEFAULTS.ttsModel : defaultModelForProviderType(providerType)), - ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions, - savedVoices, - pdfHighlightEnabled: - raw.pdfHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfHighlightEnabled, - pdfWordHighlightEnabled: - raw.pdfWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfWordHighlightEnabled, - epubHighlightEnabled: - raw.epubHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubHighlightEnabled, - epubWordHighlightEnabled: - raw.epubWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubWordHighlightEnabled, - htmlHighlightEnabled: - raw.htmlHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.htmlHighlightEnabled, - htmlWordHighlightEnabled: - raw.htmlWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.htmlWordHighlightEnabled, - firstVisit: raw.firstVisit === 'true', - documentListState, - }; - - const voiceKey = `${config.providerRef}:${config.ttsModel}`; - config.voice = config.savedVoices[voiceKey] || ''; - - return config; -} - -// Version 9: normalize app-config provider fields to providerRef/providerType. -db.version(DB_VERSION).stores({ - [PDF_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt', - [EPUB_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt', - [HTML_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt', - [APP_CONFIG_TABLE]: 'id', - [LAST_LOCATION_TABLE]: 'docId', - [DOCUMENT_ID_MAP_TABLE]: 'oldId, id, createdAt', - [PREVIEW_CACHE_TABLE]: 'docId, lastModified, cachedAt, byteSize', - // `null` here means: drop the old 'config' table after upgrade runs, - // but Dexie still lets us read it inside the upgrade transaction. - [CONFIG_TABLE]: null, -}).upgrade(async (trans) => { - const appConfigTable = trans.table(APP_CONFIG_TABLE); - const appConfig = await appConfigTable.get('singleton'); - if (appConfig) { - const legacyProviderRef = (() => { - const providerRefRaw = (appConfig as unknown as { providerRef?: unknown }).providerRef; - if (typeof providerRefRaw === 'string' && providerRefRaw.trim()) return providerRefRaw; - const ttsProviderRaw = (appConfig as unknown as { ttsProvider?: unknown }).ttsProvider; - if (typeof ttsProviderRaw === 'string' && ttsProviderRaw.trim()) return ttsProviderRaw; - const providerRaw = (appConfig as unknown as { provider?: unknown }).provider; - if (typeof providerRaw === 'string' && providerRaw.trim()) return providerRaw; - return ''; - })(); - const providerRef = normalizeLegacyProviderRef(legacyProviderRef, APP_CONFIG_DEFAULTS.providerRef) || APP_CONFIG_DEFAULTS.providerRef; - const providerType = resolveEffectiveProviderType({ providerRef }); - const savedVoices = appConfig.savedVoices && typeof appConfig.savedVoices === 'object' - ? appConfig.savedVoices - : {}; - const resolvedVoiceKey = `${providerRef}:${appConfig.ttsModel || APP_CONFIG_DEFAULTS.ttsModel}`; - const resolvedVoice = savedVoices[resolvedVoiceKey] || ''; - await appConfigTable.put({ - ...appConfig, - providerRef, - providerType, - voice: appConfig.voice || resolvedVoice, - }); - return; - } - - const configRows = await trans.table(CONFIG_TABLE).toArray(); - const raw: RawConfigMap = {}; - - for (const row of configRows) { - raw[row.key] = row.value; - } - - const built = buildAppConfigFromRaw(raw); - await appConfigTable.put(built); - - // Migrate any legacy lastLocation_* keys into the dedicated last-locations table. - const locationTable = trans.table(LAST_LOCATION_TABLE); - for (const row of configRows) { - if (row.key.startsWith('lastLocation_')) { - const docId = row.key.substring('lastLocation_'.length); - await locationTable.put({ docId, location: row.value }); - } - } -}); - -let dbOpenPromise: Promise | null = null; -const cacheTextEncoder = new TextEncoder(); - -export async function initDB(): Promise { - if (dbOpenPromise) { - return dbOpenPromise; - } - - dbOpenPromise = (async () => { - try { - console.log('Opening Dexie database...'); - emitDexieStatus('opening'); - const startedAt = Date.now(); - const stallTimer = setTimeout(() => { - emitDexieStatus('stalled', { ms: Date.now() - startedAt }); - }, 4000); - await db.open(); - await Promise.all([pruneDocumentCacheIfNeededInternal(), prunePreviewCacheIfNeededInternal()]).catch((error) => { - console.warn('Dexie cache prune on open failed:', error); - }); - clearTimeout(stallTimer); - console.log('Dexie database opened successfully'); - emitDexieStatus('opened'); - } catch (error) { - console.error('Dexie initialization error:', error); - emitDexieStatus('error', { message: error instanceof Error ? error.message : String(error) }); - dbOpenPromise = null; - throw error; - } - })(); - - return dbOpenPromise; -} - -async function withDB(operation: () => Promise): Promise { - await initDB(); - return operation(); -} - -type DocumentCacheTableName = typeof PDF_TABLE | typeof EPUB_TABLE | typeof HTML_TABLE; - -type DocumentCacheEntryRef = { - table: DocumentCacheTableName; - id: string; - byteSize: number; - accessedAt: number; -}; - -function toPositiveInt(value: unknown, fallback: number = 0): number { - const n = Number(value); - if (!Number.isFinite(n) || n < 0) return fallback; - return Math.max(0, Math.floor(n)); -} - -function byteSizeForPdfRow(row: PDFCacheRow): number { - const cached = toPositiveInt(row.cacheByteSize, 0); - if (cached > 0) return cached; - return row.data instanceof ArrayBuffer ? row.data.byteLength : toPositiveInt(row.size, 0); -} - -function byteSizeForEpubRow(row: EPUBCacheRow): number { - const cached = toPositiveInt(row.cacheByteSize, 0); - if (cached > 0) return cached; - return row.data instanceof ArrayBuffer ? row.data.byteLength : toPositiveInt(row.size, 0); -} - -function byteSizeForHtmlRow(row: HTMLCacheRow): number { - const cached = toPositiveInt(row.cacheByteSize, 0); - if (cached > 0) return cached; - if (typeof row.data === 'string') return cacheTextEncoder.encode(row.data).byteLength; - return toPositiveInt(row.size, 0); -} - -function accessedAtForRow(row: DocumentCacheMeta & { lastModified?: number }): number { - return toPositiveInt(row.cacheAccessedAt, toPositiveInt(row.lastModified, Date.now())); -} - -async function deleteDocumentCacheEntryInternal(table: DocumentCacheTableName, id: string): Promise { - if (table === PDF_TABLE) { - await db[PDF_TABLE].delete(id); - return; - } - if (table === EPUB_TABLE) { - await db[EPUB_TABLE].delete(id); - return; - } - await db[HTML_TABLE].delete(id); -} - -async function pruneDocumentCacheIfNeededInternal(): Promise { - const budget = DOCUMENT_CACHE_MAX_BYTES; - if (!Number.isFinite(budget) || budget <= 0) return; - - const [pdfRows, epubRows, htmlRows] = await Promise.all([ - db[PDF_TABLE].toArray(), - db[EPUB_TABLE].toArray(), - db[HTML_TABLE].toArray(), - ]); - - const entries: DocumentCacheEntryRef[] = []; - let totalBytes = 0; - - for (const row of pdfRows) { - const byteSize = byteSizeForPdfRow(row); - totalBytes += byteSize; - entries.push({ - table: PDF_TABLE, - id: row.id, - byteSize, - accessedAt: accessedAtForRow(row), - }); - } - for (const row of epubRows) { - const byteSize = byteSizeForEpubRow(row); - totalBytes += byteSize; - entries.push({ - table: EPUB_TABLE, - id: row.id, - byteSize, - accessedAt: accessedAtForRow(row), - }); - } - for (const row of htmlRows) { - const byteSize = byteSizeForHtmlRow(row); - totalBytes += byteSize; - entries.push({ - table: HTML_TABLE, - id: row.id, - byteSize, - accessedAt: accessedAtForRow(row), - }); - } - - if (totalBytes <= budget) return; - - entries.sort((a, b) => a.accessedAt - b.accessedAt); - for (const entry of entries) { - if (totalBytes <= budget) break; - await deleteDocumentCacheEntryInternal(entry.table, entry.id); - totalBytes -= entry.byteSize; - } -} - -async function prunePreviewCacheIfNeededInternal(): Promise { - const budget = PREVIEW_CACHE_MAX_BYTES; - if (!Number.isFinite(budget) || budget <= 0) return; - - const rows = await db[PREVIEW_CACHE_TABLE].toArray(); - let totalBytes = 0; - const entries = rows.map((row) => { - const byteSize = toPositiveInt( - row.byteSize, - row.data instanceof ArrayBuffer ? row.data.byteLength : 0, - ); - totalBytes += byteSize; - return { - docId: row.docId, - byteSize, - accessedAt: toPositiveInt(row.cachedAt, 0), - }; - }); - - if (totalBytes <= budget) return; - - entries.sort((a, b) => a.accessedAt - b.accessedAt); - for (const entry of entries) { - if (totalBytes <= budget) break; - await db[PREVIEW_CACHE_TABLE].delete(entry.docId); - totalBytes -= entry.byteSize; - } -} - -function isSha256HexId(value: string): boolean { - return /^[a-f0-9]{64}$/i.test(value); -} - -async function getMappedDocumentId(docId: string): Promise { - if (isSha256HexId(docId)) return docId.toLowerCase(); - const row = await db[DOCUMENT_ID_MAP_TABLE].get(docId); - return row?.id ?? docId; -} - -export async function resolveDocumentId(docId: string): Promise { - return withDB(async () => getMappedDocumentId(docId)); -} - -async function recordDocumentIdMapping(oldId: string, id: string): Promise { - if (oldId === id) return; - await db[DOCUMENT_ID_MAP_TABLE].put({ oldId, id, createdAt: Date.now() }); -} - -function rewriteDocumentListStateDocIds(state: DocumentListState, mapping: Map): DocumentListState { - let didChange = false; - - const folders = state.folders.map((folder) => { - let folderChanged = false; - const seen = new Set(); - const documents: DocumentListDocument[] = []; - - for (const doc of folder.documents) { - const mappedId = mapping.get(doc.id) ?? doc.id; - if (mappedId !== doc.id) folderChanged = true; - if (seen.has(mappedId)) { - folderChanged = true; - continue; - } - seen.add(mappedId); - documents.push(mappedId === doc.id ? doc : { ...doc, id: mappedId }); - } - - if (!folderChanged) return folder; - didChange = true; - return { ...folder, documents }; - }); - - return didChange ? { ...state, folders } : state; -} - -async function applyDocumentIdMapping(oldId: string, newId: string): Promise { - if (!oldId || !newId || oldId === newId) return; - const nextId = newId.toLowerCase(); - - await withDB(async () => { - await db.transaction( - 'readwrite', - [ - db[PDF_TABLE], - db[EPUB_TABLE], - db[HTML_TABLE], - db[LAST_LOCATION_TABLE], - db[APP_CONFIG_TABLE], - db[DOCUMENT_ID_MAP_TABLE], - db[PREVIEW_CACHE_TABLE], - ], - async () => { - await recordDocumentIdMapping(oldId, nextId); - - const pdf = await db[PDF_TABLE].get(oldId); - if (pdf) { - const existing = await db[PDF_TABLE].get(nextId); - if (existing) { - const merged: PDFDocument = { - ...pdf, - ...existing, - id: nextId, - folderId: existing.folderId ?? pdf.folderId, - name: existing.name || pdf.name, - }; - await db[PDF_TABLE].put(merged); - await db[PDF_TABLE].delete(oldId); - } else { - await db[PDF_TABLE].put({ ...pdf, id: nextId }); - await db[PDF_TABLE].delete(oldId); - } - } - - const epub = await db[EPUB_TABLE].get(oldId); - if (epub) { - const existing = await db[EPUB_TABLE].get(nextId); - if (existing) { - const merged: EPUBDocument = { - ...epub, - ...existing, - id: nextId, - folderId: existing.folderId ?? epub.folderId, - name: existing.name || epub.name, - }; - await db[EPUB_TABLE].put(merged); - await db[EPUB_TABLE].delete(oldId); - } else { - await db[EPUB_TABLE].put({ ...epub, id: nextId }); - await db[EPUB_TABLE].delete(oldId); - } - } - - const html = await db[HTML_TABLE].get(oldId); - if (html) { - const existing = await db[HTML_TABLE].get(nextId); - if (existing) { - const merged: HTMLDocument = { - ...html, - ...existing, - id: nextId, - folderId: existing.folderId ?? html.folderId, - name: existing.name || html.name, - }; - await db[HTML_TABLE].put(merged); - await db[HTML_TABLE].delete(oldId); - } else { - await db[HTML_TABLE].put({ ...html, id: nextId }); - await db[HTML_TABLE].delete(oldId); - } - } - - const oldLocation = await db[LAST_LOCATION_TABLE].get(oldId); - if (oldLocation) { - const newLocation = await db[LAST_LOCATION_TABLE].get(nextId); - if (!newLocation) { - await db[LAST_LOCATION_TABLE].put({ docId: nextId, location: oldLocation.location }); - } - await db[LAST_LOCATION_TABLE].delete(oldId); - } - - const preview = await db[PREVIEW_CACHE_TABLE].get(oldId); - if (preview) { - const existing = await db[PREVIEW_CACHE_TABLE].get(nextId); - if (!existing || Number(existing.cachedAt ?? 0) < Number(preview.cachedAt ?? 0)) { - await db[PREVIEW_CACHE_TABLE].put({ ...preview, docId: nextId }); - } - await db[PREVIEW_CACHE_TABLE].delete(oldId); - } - - const appConfig = await db[APP_CONFIG_TABLE].get('singleton'); - if (appConfig?.documentListState) { - const mapped = rewriteDocumentListStateDocIds(appConfig.documentListState, new Map([[oldId, nextId]])); - if (mapped !== appConfig.documentListState) { - await db[APP_CONFIG_TABLE].update('singleton', { documentListState: mapped }); - } - } - }, - ); - }); -} - -export async function migrateLegacyDexieDocumentIdsToSha(): Promise> { - return withDB(async () => { - const mappings: Array<{ oldId: string; id: string }> = []; - - const pdfDocs = await db[PDF_TABLE].toArray(); - for (const doc of pdfDocs) { - if (isSha256HexId(doc.id)) continue; - const id = await sha256HexFromBytes(new Uint8Array(doc.data)); - if (id !== doc.id) { - mappings.push({ oldId: doc.id, id }); - await applyDocumentIdMapping(doc.id, id); - } - } - - const epubDocs = await db[EPUB_TABLE].toArray(); - for (const doc of epubDocs) { - if (isSha256HexId(doc.id)) continue; - const id = await sha256HexFromBytes(new Uint8Array(doc.data)); - if (id !== doc.id) { - mappings.push({ oldId: doc.id, id }); - await applyDocumentIdMapping(doc.id, id); - } - } - - const htmlDocs = await db[HTML_TABLE].toArray(); - for (const doc of htmlDocs) { - if (isSha256HexId(doc.id)) continue; - const id = await sha256HexFromString(doc.data); - if (id !== doc.id) { - mappings.push({ oldId: doc.id, id }); - await applyDocumentIdMapping(doc.id, id); - } - } - - return mappings; - }); -} - -export async function getDocumentIdMappings(): Promise> { - return withDB(async () => { - const rows = await db[DOCUMENT_ID_MAP_TABLE].toArray(); - return rows.map((row) => ({ oldId: row.oldId, id: row.id })); - }); -} - -// PDF document helpers - -export async function addPdfDocument(document: PDFDocument): Promise { - await withDB(async () => { - console.log('Adding PDF document via Dexie:', document.name); - const now = Date.now(); - await db[PDF_TABLE].put({ - ...document, - cacheCreatedAt: now, - cacheAccessedAt: now, - cacheByteSize: document.data.byteLength, - }); - await pruneDocumentCacheIfNeededInternal(); - }); -} - -export async function getPdfDocument(id: string): Promise { - return withDB(async () => { - console.log('Fetching PDF document via Dexie:', id); - const resolved = await getMappedDocumentId(id); - const row = await db[PDF_TABLE].get(resolved); - if (row) { - await db[PDF_TABLE].update(resolved, { cacheAccessedAt: Date.now() }); - } - return row; - }); -} - -export async function getAllPdfDocuments(): Promise { - return withDB(async () => { - console.log('Fetching all PDF documents via Dexie'); - return db[PDF_TABLE].toArray(); - }); -} - -export async function removePdfDocument(id: string): Promise { - await withDB(async () => { - console.log('Removing PDF document via Dexie:', id); - const resolved = await getMappedDocumentId(id); - await db.transaction('readwrite', db[PDF_TABLE], db[LAST_LOCATION_TABLE], db[PREVIEW_CACHE_TABLE], async () => { - await db[PDF_TABLE].delete(resolved); - await db[LAST_LOCATION_TABLE].delete(resolved); - await db[PREVIEW_CACHE_TABLE].delete(resolved); - }); - }); -} - -export async function clearPdfDocuments(): Promise { - await withDB(async () => { - console.log('Clearing all PDF documents via Dexie'); - await db[PDF_TABLE].clear(); - }); -} - -// EPUB document helpers - -export async function addEpubDocument(document: EPUBDocument): Promise { - await withDB(async () => { - if (document.data.byteLength === 0) { - throw new Error('Cannot store empty ArrayBuffer'); - } - - console.log('Adding EPUB document via Dexie:', { - name: document.name, - size: document.size, - actualSize: document.data.byteLength, - }); - - const now = Date.now(); - await db[EPUB_TABLE].put({ - ...document, - cacheCreatedAt: now, - cacheAccessedAt: now, - cacheByteSize: document.data.byteLength, - }); - await pruneDocumentCacheIfNeededInternal(); - }); -} - -export async function getEpubDocument(id: string): Promise { - return withDB(async () => { - console.log('Fetching EPUB document via Dexie:', id); - const resolved = await getMappedDocumentId(id); - const row = await db[EPUB_TABLE].get(resolved); - if (row) { - await db[EPUB_TABLE].update(resolved, { cacheAccessedAt: Date.now() }); - } - return row; - }); -} - -export async function getAllEpubDocuments(): Promise { - return withDB(async () => { - console.log('Fetching all EPUB documents via Dexie'); - return db[EPUB_TABLE].toArray(); - }); -} - -export async function removeEpubDocument(id: string): Promise { - await withDB(async () => { - console.log('Removing EPUB document via Dexie:', id); - const resolved = await getMappedDocumentId(id); - await db.transaction('readwrite', db[EPUB_TABLE], db[LAST_LOCATION_TABLE], db[PREVIEW_CACHE_TABLE], async () => { - await db[EPUB_TABLE].delete(resolved); - await db[LAST_LOCATION_TABLE].delete(resolved); - await db[PREVIEW_CACHE_TABLE].delete(resolved); - }); - }); -} - -export async function clearEpubDocuments(): Promise { - await withDB(async () => { - console.log('Clearing all EPUB documents via Dexie'); - await db[EPUB_TABLE].clear(); - }); -} - -// HTML / text document helpers - -export async function addHtmlDocument(document: HTMLDocument): Promise { - await withDB(async () => { - console.log('Adding HTML document via Dexie:', document.name); - const now = Date.now(); - await db[HTML_TABLE].put({ - ...document, - cacheCreatedAt: now, - cacheAccessedAt: now, - cacheByteSize: cacheTextEncoder.encode(document.data).byteLength, - }); - await pruneDocumentCacheIfNeededInternal(); - }); -} - -export async function getHtmlDocument(id: string): Promise { - return withDB(async () => { - console.log('Fetching HTML document via Dexie:', id); - const resolved = await getMappedDocumentId(id); - const row = await db[HTML_TABLE].get(resolved); - if (row) { - await db[HTML_TABLE].update(resolved, { cacheAccessedAt: Date.now() }); - } - return row; - }); -} - -export async function getAllHtmlDocuments(): Promise { - return withDB(async () => { - console.log('Fetching all HTML documents via Dexie'); - return db[HTML_TABLE].toArray(); - }); -} - -export async function removeHtmlDocument(id: string): Promise { - await withDB(async () => { - console.log('Removing HTML document via Dexie:', id); - const resolved = await getMappedDocumentId(id); - await db.transaction('readwrite', db[HTML_TABLE], db[PREVIEW_CACHE_TABLE], async () => { - await db[HTML_TABLE].delete(resolved); - await db[PREVIEW_CACHE_TABLE].delete(resolved); - }); - }); -} - -export async function clearHtmlDocuments(): Promise { - await withDB(async () => { - console.log('Clearing all HTML documents via Dexie'); - await db[HTML_TABLE].clear(); - }); -} - -export async function getDocumentRecentlyOpenedMap(): Promise> { - return withDB(async () => { - const byId: Record = {}; - const write = (identity: string, ts: unknown) => { - const value = toPositiveInt(ts, 0); - if (value <= 0) return; - if (!byId[identity] || value > byId[identity]) byId[identity] = value; - }; - - await Promise.all([ - db[PDF_TABLE] - .orderBy('cacheAccessedAt') - .eachKey((cacheAccessedAt, cursor) => write(`pdf|${String(cursor.primaryKey)}`, cacheAccessedAt)), - db[EPUB_TABLE] - .orderBy('cacheAccessedAt') - .eachKey((cacheAccessedAt, cursor) => write(`epub|${String(cursor.primaryKey)}`, cacheAccessedAt)), - db[HTML_TABLE] - .orderBy('cacheAccessedAt') - .eachKey((cacheAccessedAt, cursor) => write(`html|${String(cursor.primaryKey)}`, cacheAccessedAt)), - ]); - - return byId; - }); -} - -export async function getAppConfig(): Promise { - return withDB(async () => { - const row = await db[APP_CONFIG_TABLE].get('singleton'); - return row ?? null; - }); -} - -export async function updateAppConfig(partial: Partial): Promise { - await withDB(async () => { - const table = db[APP_CONFIG_TABLE]; - const existing = await table.get('singleton'); - - if (!existing) { - await table.put({ - id: 'singleton', - ...APP_CONFIG_DEFAULTS, - ...partial, - }); - } else { - await table.update('singleton', partial); - } - }); -} - -// Document list state helpers - -export async function saveDocumentListState(state: DocumentListState): Promise { - await updateAppConfig({ documentListState: state }); -} - -export async function getDocumentListState(): Promise { - const config = await getAppConfig(); - if (!config || !config.documentListState) return null; - return config.documentListState; -} - -// Last-location helpers (used by TTS and readers) - -export async function getLastDocumentLocation(docId: string): Promise { - return withDB(async () => { - const resolved = await getMappedDocumentId(docId); - const row = await db[LAST_LOCATION_TABLE].get(resolved); - return row ? row.location : null; - }); -} - -export async function setLastDocumentLocation(docId: string, location: string): Promise { - await withDB(async () => { - const resolved = await getMappedDocumentId(docId); - await db[LAST_LOCATION_TABLE].put({ docId: resolved, location }); - }); -} - -// First-visit helpers (used for onboarding/Settings modal) - -export async function getFirstVisit(): Promise { - const config = await getAppConfig(); - return config?.firstVisit ?? false; -} - -export async function setFirstVisit(value: boolean): Promise { - await updateAppConfig({ firstVisit: value }); -} - -// Document preview cache helpers - -export async function getDocumentPreviewCache(docId: string): Promise { - return withDB(async () => { - const resolved = await getMappedDocumentId(docId); - const row = await db[PREVIEW_CACHE_TABLE].get(resolved); - if (row) { - await db[PREVIEW_CACHE_TABLE].update(resolved, { cachedAt: Date.now() }); - } - return row; - }); -} - -export async function putDocumentPreviewCache(row: DocumentPreviewCacheRow): Promise { - await withDB(async () => { - const resolved = await getMappedDocumentId(row.docId); - await db[PREVIEW_CACHE_TABLE].put({ - ...row, - docId: resolved, - cachedAt: Date.now(), - byteSize: toPositiveInt(row.byteSize, row.data.byteLength), - }); - await prunePreviewCacheIfNeededInternal(); - }); -} - -export async function removeDocumentPreviewCache(docId: string): Promise { - await withDB(async () => { - const resolved = await getMappedDocumentId(docId); - await db[PREVIEW_CACHE_TABLE].delete(resolved); - }); -} - -export async function clearDocumentPreviewCache(): Promise { - await withDB(async () => { - await db[PREVIEW_CACHE_TABLE].clear(); - }); -} - -// Sync helpers (server round-trip) - -export async function syncDocumentsToServer( - onProgress?: (progress: number, status?: string) => void, - signal?: AbortSignal, -): Promise<{ lastSync: number }> { - const pdfDocs = await getAllPdfDocuments(); - const epubDocs = await getAllEpubDocuments(); - const htmlDocs = await getAllHtmlDocuments(); - - const uploads: Array<{ oldId: string; source: UploadSource }> = []; - const totalDocs = pdfDocs.length + epubDocs.length + htmlDocs.length; - let processedDocs = 0; - - const textEncoder = new TextEncoder(); - - for (const doc of pdfDocs) { - const bytes = new Uint8Array(doc.data); - uploads.push({ - oldId: doc.id, - source: { - name: doc.name, - type: 'pdf', - size: bytes.byteLength, - lastModified: doc.lastModified, - contentType: 'application/pdf', - body: bytes, - }, - }); - processedDocs++; - if (onProgress) { - onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`); - } - } - - for (const doc of epubDocs) { - const bytes = new Uint8Array(doc.data); - uploads.push({ - oldId: doc.id, - source: { - name: doc.name, - type: 'epub', - size: bytes.byteLength, - lastModified: doc.lastModified, - contentType: 'application/epub+zip', - body: bytes, - }, - }); - processedDocs++; - if (onProgress) { - onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`); - } - } - - for (const doc of htmlDocs) { - const encoded = textEncoder.encode(doc.data); - uploads.push({ - oldId: doc.id, - source: { - name: doc.name, - type: 'html', - size: encoded.byteLength, - lastModified: doc.lastModified, - contentType: 'text/plain; charset=utf-8', - body: encoded, - }, - }); - processedDocs++; - if (onProgress) { - onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`); - } - } - - if (onProgress) { - onProgress(50, 'Uploading to server...'); - } - - const stored = await uploadDocumentSources(uploads.map((entry) => entry.source), { signal }); - - for (let index = 0; index < uploads.length; index += 1) { - const entry = uploads[index]; - const nextId = stored[index]?.id; - if (!nextId || entry.oldId === nextId) continue; - await applyDocumentIdMapping(entry.oldId, nextId); - } - - if (onProgress) { - onProgress(100, 'Upload complete!'); - } - - return { lastSync: Date.now() }; -} - -export async function syncSelectedDocumentsToServer( - documents: BaseDocument[], - onProgress?: (progress: number, status?: string) => void, - signal?: AbortSignal, -): Promise<{ lastSync: number }> { - const uploads: Array<{ oldId: string; source: UploadSource }> = []; - const textEncoder = new TextEncoder(); - let processed = 0; - - for (const doc of documents) { - if (doc.type === 'pdf') { - const data = await getPdfDocument(doc.id); - if (data) { - const bytes = new Uint8Array(data.data); - uploads.push({ - oldId: data.id, - source: { - name: data.name, - type: 'pdf', - size: bytes.byteLength, - lastModified: data.lastModified, - contentType: 'application/pdf', - body: bytes, - }, - }); - } - } else if (doc.type === 'epub') { - const data = await getEpubDocument(doc.id); - if (data) { - const bytes = new Uint8Array(data.data); - uploads.push({ - oldId: data.id, - source: { - name: data.name, - type: 'epub', - size: bytes.byteLength, - lastModified: data.lastModified, - contentType: 'application/epub+zip', - body: bytes, - }, - }); - } - } else { - const data = await getHtmlDocument(doc.id); - if (data) { - const bytes = textEncoder.encode(data.data); - uploads.push({ - oldId: data.id, - source: { - name: data.name, - type: 'html', - size: bytes.byteLength, - lastModified: data.lastModified, - contentType: 'text/plain; charset=utf-8', - body: bytes, - }, - }); - } - } - - processed++; - if (onProgress) onProgress((processed / documents.length) * 50, `Preparing ${processed}/${documents.length}...`); - } - - if (onProgress) onProgress(50, 'Uploading to server...'); - const stored = await uploadDocumentSources(uploads.map((entry) => entry.source), { signal }); - - for (let index = 0; index < uploads.length; index += 1) { - const entry = uploads[index]; - const nextId = stored[index]?.id; - if (!nextId || entry.oldId === nextId) continue; - await applyDocumentIdMapping(entry.oldId, nextId); - } - - if (onProgress) { - onProgress(100, 'Upload complete!'); - } - - return { lastSync: Date.now() }; -} - - -export async function loadDocumentsFromServer( - onProgress?: (progress: number, status?: string) => void, - signal?: AbortSignal, -): Promise<{ lastSync: number }> { - if (onProgress) { - onProgress(10, 'Starting download...'); - } - - const documents = await listDocuments({ signal }); - await downloadAndCacheServerDocuments(documents, onProgress, signal); - - if (onProgress) { - onProgress(100, 'Load complete!'); - } - - return { lastSync: Date.now() }; -} - -export async function loadSelectedDocumentsFromServer( - selectedIds: string[], - onProgress?: (progress: number, status?: string) => void, - signal?: AbortSignal, -): Promise<{ lastSync: number }> { - if (onProgress) { - onProgress(10, 'Starting download...'); - } - - const documents = await listDocuments({ ids: selectedIds, signal }); - await downloadAndCacheServerDocuments(documents, onProgress, signal); - - if (onProgress) { - onProgress(100, 'Load complete!'); - } - - return { lastSync: Date.now() }; -} - -async function downloadAndCacheServerDocuments( - documents: BaseDocument[], - onProgress?: (progress: number, status?: string) => void, - signal?: AbortSignal, -) { - if (onProgress) onProgress(30, 'List complete'); - if (documents.length === 0) { - if (onProgress) onProgress(95, 'No documents to import'); - return; - } - - for (let i = 0; i < documents.length; i++) { - const doc = documents[i]; - const bytes = await downloadDocumentContent(doc.id, { signal }); - await cacheStoredDocumentFromBytes(doc, bytes); - if (onProgress) { - onProgress(30 + ((i + 1) / documents.length) * 65, `Downloading ${i + 1}/${documents.length}: ${doc.name}`); - } - } -} - - -export async function importSelectedDocuments( - documents: BaseDocument[], - onProgress?: (progress: number, status?: string) => void, - signal?: AbortSignal, -): Promise { - if (documents.length === 0) return; - - const textDecoder = new TextDecoder(); - - for (let i = 0; i < documents.length; i++) { - const doc = documents[i]; - - if (onProgress) { - onProgress(10 + (i / documents.length) * 85, `Downloading ${i + 1}/${documents.length}: ${doc.name}`); - } - - const contentResponse = await fetch(`/api/documents/library/content?id=${encodeURIComponent(doc.id)}`, { signal }); - if (!contentResponse.ok) { - console.warn(`Failed to download library document: ${doc.name}`); - continue; - } - - const buffer = await contentResponse.arrayBuffer(); - const bytes = new Uint8Array(buffer); - - if (doc.type === 'pdf') { - const localId = await sha256HexFromBytes(bytes); - await addPdfDocument({ - id: localId, - type: 'pdf', - name: doc.name, - size: bytes.byteLength, - lastModified: doc.lastModified, - data: buffer, - }); - } else if (doc.type === 'epub') { - const localId = await sha256HexFromBytes(bytes); - await addEpubDocument({ - id: localId, - type: 'epub', - name: doc.name, - size: bytes.byteLength, - lastModified: doc.lastModified, - data: buffer, - }); - } else { - const decoded = textDecoder.decode(bytes); - const localId = await sha256HexFromString(decoded); - await addHtmlDocument({ - id: localId, - type: 'html', - name: doc.name, - size: bytes.byteLength, - lastModified: doc.lastModified, - data: decoded, - }); - } - - if (onProgress) { - onProgress(10 + ((i + 1) / documents.length) * 85, `Imported ${i + 1}/${documents.length}`); - } - } -} - -export async function importDocumentsFromLibrary( - onProgress?: (progress: number, status?: string) => void, - signal?: AbortSignal, -): Promise { - if (onProgress) { - onProgress(5, 'Scanning server library...'); - } - - const listResponse = await fetch('/api/documents/library', { signal }); - if (!listResponse.ok) { - throw new Error('Failed to list library documents'); - } - - const { documents } = (await listResponse.json()) as { documents: BaseDocument[] }; - - if (documents.length === 0) { - if (onProgress) { - onProgress(100, 'No documents found in server library'); - } - return; - } - - if (onProgress) { - onProgress(10, `Found ${documents.length} documents. Importing...`); - } - - await importSelectedDocuments(documents, onProgress, signal); - - if (onProgress) { - onProgress(100, 'Library import complete!'); - } -} diff --git a/src/lib/client/epub/location-controller.ts b/src/lib/client/epub/location-controller.ts new file mode 100644 index 0000000..7243f18 --- /dev/null +++ b/src/lib/client/epub/location-controller.ts @@ -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; +} diff --git a/src/lib/client/onboarding-flow.ts b/src/lib/client/onboarding-flow.ts index cac1f6d..10bb6f3 100644 --- a/src/lib/client/onboarding-flow.ts +++ b/src/lib/client/onboarding-flow.ts @@ -1,11 +1,10 @@ -export type OnboardingStep = 'privacy' | 'claim' | 'migration' | 'changelog' | 'done'; +export type OnboardingStep = 'privacy' | 'claim' | 'changelog' | 'done'; export type OnboardingStepSnapshot = { privacyRequired: boolean; privacyAccepted: boolean; claimEligible: boolean; claimHasData: boolean; - migrationRequired: boolean; changelogPending: boolean; }; @@ -18,10 +17,6 @@ export function resolveNextOnboardingStep(snapshot: OnboardingStepSnapshot): Onb return 'claim'; } - if (snapshot.migrationRequired) { - return 'migration'; - } - if (snapshot.changelogPending) { return 'changelog'; } diff --git a/src/lib/client/query-keys.ts b/src/lib/client/query-keys.ts new file mode 100644 index 0000000..da51b0c --- /dev/null +++ b/src/lib/client/query-keys.ts @@ -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, +}; diff --git a/src/lib/server/admin/resolve-credentials.ts b/src/lib/server/admin/resolve-credentials.ts index 39c039f..cf4f1ca 100644 --- a/src/lib/server/admin/resolve-credentials.ts +++ b/src/lib/server/admin/resolve-credentials.ts @@ -8,7 +8,7 @@ import { export interface ResolvedTtsCredentials { /** Provider id passed downstream to TTS generation (one of the 4 built-in IDs). */ provider: string; - /** API key for the request. Empty string when neither admin nor user supplied one. */ + /** Decrypted API key from the selected admin-managed provider. */ apiKey: string; /** Base URL, or undefined to fall through to provider defaults. */ baseUrl: string | undefined; @@ -21,62 +21,26 @@ export interface ResolvedTtsCredentials { /** * Resolve TTS credentials for an incoming request. * - * 1. If `restrictUserApiKeys` is enabled, only admin shared providers are - * used. Built-in provider ids and user-supplied key/base headers are - * ignored. - * 2. If `providerHeader` matches an enabled admin provider slug, use its - * server-stored credentials. Any `x-openai-key` / `x-openai-base-url` - * headers from the client are ignored — admin keys must never round-trip - * through the client. - * 3. Otherwise, treat `providerHeader` as a built-in provider id and use the - * per-user `x-openai-key` / `x-openai-base-url` headers as today. + * Only admin-managed shared providers can supply credentials. Built-in + * provider ids select the preferred enabled shared provider of that type. * * Returns `null` when the request references a slug that exists but is * disabled — callers should reject with a 4xx. */ export async function resolveTtsCredentials(opts: { providerHeader: string | null; - apiKeyHeader: string | null; - baseUrlHeader: string | null; fallbackProvider?: string; - restrictUserApiKeys?: boolean; }): Promise { const requestedProvider = opts.providerHeader || opts.fallbackProvider || 'openai'; - if (opts.restrictUserApiKeys) { - const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider); - const fallback = opts.fallbackProvider || ''; - const selected = await resolvePreferredEnabledAdminProvider({ - requestedSlug: requestedIsBuiltIn ? null : requestedProvider, - runtimeDefaultSlug: fallback, - }); - if (!selected) { - return { error: 'no_shared_provider_configured', slug: requestedProvider }; - } - const apiKey = await decryptedKeyFor(selected); - return { - provider: selected.providerType, - apiKey, - baseUrl: selected.baseUrl || undefined, - fromAdmin: true, - adminRecord: selected, - }; - } - - // Built-in provider ids are not admin slugs — short-circuit. - if (isBuiltInProviderId(requestedProvider)) { - return { - provider: requestedProvider, - apiKey: opts.apiKeyHeader || '', - baseUrl: opts.baseUrlHeader || undefined, - fromAdmin: false, - }; - } - - // Not a built-in id → try to look it up as an admin slug. - const admin = await getEnabledAdminProviderBySlug(requestedProvider); + const admin = isBuiltInProviderId(requestedProvider) + ? await resolvePreferredEnabledAdminProvider({ + requestedSlug: null, + runtimeDefaultSlug: opts.fallbackProvider || '', + }) + : await getEnabledAdminProviderBySlug(requestedProvider); if (!admin) { - return { error: 'provider_unknown', slug: requestedProvider }; + return { error: 'no_shared_provider_configured', slug: requestedProvider }; } const apiKey = await decryptedKeyFor(admin); diff --git a/src/lib/server/user/claim-data.ts b/src/lib/server/user/claim-data.ts index 259425e..64d2b0b 100644 --- a/src/lib/server/user/claim-data.ts +++ b/src/lib/server/user/claim-data.ts @@ -8,6 +8,8 @@ import { ttsSegmentVariants, userPreferences, userDocumentProgress, + userFolders, + userOnboarding, } from '@openreader/database/schema'; import { eq, and, inArray } from 'drizzle-orm'; import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy'; @@ -115,7 +117,7 @@ export async function claimAnonymousData( options?: { cleanupLegacySources?: boolean }, ) { if (!userId) { - return { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0 }; + return { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0, folders: 0, onboarding: 0 }; } const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([ @@ -129,13 +131,16 @@ export async function claimAnonymousData( .where(eq(audiobooks.userId, unclaimedUserId)) as Promise>, ]); - const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed] = await Promise.all([ + const foldersClaimed = await transferUserFolders(unclaimedUserId, userId); + const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed, onboardingClaimed] = await Promise.all([ transferUserDocuments(unclaimedUserId, userId, { namespace, transferTts: true }), transferUserAudiobooks(unclaimedUserId, userId, namespace), transferUserPreferences(unclaimedUserId, userId), transferUserProgress(unclaimedUserId, userId), transferUserDocumentSettings(unclaimedUserId, userId), + transferUserOnboarding(unclaimedUserId, userId), ]); + await db.delete(userFolders).where(eq(userFolders.userId, unclaimedUserId)); if ( options?.cleanupLegacySources !== false @@ -168,9 +173,30 @@ export async function claimAnonymousData( preferences: preferencesClaimed, progress: progressClaimed, documentSettings: documentSettingsClaimed, + folders: foldersClaimed, + onboarding: onboardingClaimed, }; } +export async function transferUserFolders(fromUserId: string, toUserId: string): Promise { + 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 { + 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. * diff --git a/src/lib/server/user/data-export.ts b/src/lib/server/user/data-export.ts index 8e173f3..3a5734c 100644 --- a/src/lib/server/user/data-export.ts +++ b/src/lib/server/user/data-export.ts @@ -61,6 +61,8 @@ export type AppendUserExportArchiveInput = { exportedAtMs: number; profileData: unknown; preferences: unknown | null; + folders?: unknown[]; + onboarding?: unknown | null; readingHistory: unknown[]; ttsUsage: unknown[]; jobEvents: unknown[]; @@ -144,6 +146,8 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu exportedAtMs, profileData, preferences, + folders = [], + onboarding = null, readingHistory, ttsUsage, jobEvents, @@ -171,6 +175,8 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu if (preferences) { appendJson(archive, 'preferences.json', preferences); } + appendJson(archive, 'folders.json', folders); + if (onboarding) appendJson(archive, 'onboarding.json', onboarding); appendJson(archive, 'reading_history.json', readingHistory); appendJson(archive, 'tts_usage.json', ttsUsage); appendJson(archive, 'job_events.json', jobEvents); diff --git a/src/lib/server/user/preferences-normalize.ts b/src/lib/server/user/preferences-normalize.ts index 51d0a4e..3ed441b 100644 --- a/src/lib/server/user/preferences-normalize.ts +++ b/src/lib/server/user/preferences-normalize.ts @@ -125,6 +125,11 @@ export function sanitizePreferencesPatch( case 'savedVoices': out[key] = sanitizeSavedVoices(value); break; + case 'documentListState': + if (isRecord(value)) { + out[key] = { ...value, folders: [] } as unknown as SyncedPreferencesPatch[typeof key]; + } + break; default: break; } diff --git a/src/lib/server/user/preferences-payload.ts b/src/lib/server/user/preferences-payload.ts deleted file mode 100644 index d6f750b..0000000 --- a/src/lib/server/user/preferences-payload.ts +++ /dev/null @@ -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 & { - lastSeenAppVersion?: string; -}; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -export function deserializeUserPreferencesPayload(value: unknown): Record { - 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, - meta: UserPreferencesMeta, -): Record { - const out = { ...payload }; - delete out[USER_PREFERENCES_META_KEY]; - if (Object.keys(meta).length > 0) { - out[USER_PREFERENCES_META_KEY] = meta; - } - return out; -} diff --git a/src/lib/shared/onboarding-state.ts b/src/lib/shared/onboarding-state.ts deleted file mode 100644 index 8b8723d..0000000 --- a/src/lib/shared/onboarding-state.ts +++ /dev/null @@ -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; - -export type OnboardingStateKey = keyof typeof ONBOARDING_STATE_REGISTRY; - diff --git a/src/types/config.ts b/src/types/config.ts index 711b634..858e71c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -29,8 +29,6 @@ export function clampTtsSegmentMaxBlockLength(value: number | undefined | null): } export interface AppConfigValues { - apiKey: string; - baseUrl: string; viewType: ViewType; voiceSpeed: number; audioPlayerSpeed: number; @@ -55,10 +53,7 @@ export interface AppConfigValues { epubWordHighlightEnabled: boolean; htmlHighlightEnabled: boolean; htmlWordHighlightEnabled: boolean; - firstVisit: boolean; documentListState: DocumentListState; - privacyAccepted: boolean; - documentsMigrationPrompted: boolean; } /** @@ -75,8 +70,6 @@ export function getAppConfigDefaults(): AppConfigValues { // provider id (the old 'custom-openai') into every user's config, since that // value isn't actually selectable in shared-provider mode. return { - apiKey: '', - baseUrl: '', viewType: 'single', voiceSpeed: 1, audioPlayerSpeed: 1, @@ -101,7 +94,6 @@ export function getAppConfigDefaults(): AppConfigValues { epubWordHighlightEnabled: true, htmlHighlightEnabled: true, htmlWordHighlightEnabled: true, - firstVisit: false, documentListState: { sortBy: 'name', sortDirection: 'asc', @@ -110,8 +102,6 @@ export function getAppConfigDefaults(): AppConfigValues { showHint: true, viewMode: 'grid', }, - privacyAccepted: false, - documentsMigrationPrompted: false, }; } diff --git a/src/types/documents.ts b/src/types/documents.ts index 98802b5..b9914f2 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -6,6 +6,7 @@ export interface BaseDocument { size: number; lastModified: number; recentlyOpenedAt?: number; + contentVersion?: string; type: DocumentType; scope?: 'user'; folderId?: string; diff --git a/src/types/user-state.ts b/src/types/user-state.ts index 3fc796e..0ae9704 100644 --- a/src/types/user-state.ts +++ b/src/types/user-state.ts @@ -25,6 +25,7 @@ export const SYNCED_PREFERENCE_KEYS = [ 'epubWordHighlightEnabled', 'htmlHighlightEnabled', 'htmlWordHighlightEnabled', + 'documentListState', ] as const; export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number]; diff --git a/tests/folders.spec.ts b/tests/folders.spec.ts index 47035a2..7c44794 100644 --- a/tests/folders.spec.ts +++ b/tests/folders.spec.ts @@ -84,7 +84,7 @@ test.describe('Document folders and hint persistence', () => { // Hint should disappear await expect(hint).toHaveCount(0); - // Ensure the dismissal has been persisted to IndexedDB before reloading + // Ensure the dismissal has been persisted to server preferences before reloading await waitForDocumentListHintPersist(page, false); // Reload and ensure it remains dismissed diff --git a/tests/helpers.ts b/tests/helpers.ts index 9d280f7..9cefaff 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -533,27 +533,14 @@ export async function triggerViewportResize(page: Page, width: number, height: n await page.setViewportSize({ width, height }); } -// Wait for DocumentListState.showHint to persist in IndexedDB 'app-config' store +// Wait for DocumentListState.showHint to persist in server preferences. export async function waitForDocumentListHintPersist(page: Page, expected: boolean) { await page.waitForFunction(async (exp) => { try { - const openDb = () => new Promise((resolve, reject) => { - const req = indexedDB.open('openreader-db'); - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); - const db = await openDb(); - const readConfig = () => new Promise((resolve, reject) => { - const tx = db.transaction(['app-config'], 'readonly'); - const store = tx.objectStore('app-config'); - const getReq = store.get('singleton'); - getReq.onsuccess = () => resolve(getReq.result); - getReq.onerror = () => reject(getReq.error); - }); - const item = await readConfig(); - db.close(); - if (!item || typeof item !== 'object') return false; - const state = (item as { documentListState?: unknown }).documentListState; + const response = await fetch('/api/user/state/preferences', { cache: 'no-store' }); + if (!response.ok) return false; + const item = await response.json() as { preferences?: { documentListState?: unknown } }; + const state = item.preferences?.documentListState; if (!state || typeof state !== 'object') return false; const showHint = (state as { showHint?: unknown }).showHint; return typeof showHint === 'boolean' && showHint === exp; diff --git a/tests/unit/blob-cache.vitest.spec.ts b/tests/unit/blob-cache.vitest.spec.ts new file mode 100644 index 0000000..7b50c85 --- /dev/null +++ b/tests/unit/blob-cache.vitest.spec.ts @@ -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'); + }); +}); diff --git a/tests/unit/document-cache.vitest.spec.ts b/tests/unit/document-cache.vitest.spec.ts deleted file mode 100644 index c807c39..0000000 --- a/tests/unit/document-cache.vitest.spec.ts +++ /dev/null @@ -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(); - 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(); - 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'); - }); -}); diff --git a/tests/unit/epub-location-controller.vitest.spec.ts b/tests/unit/epub-location-controller.vitest.spec.ts index c784eab..df5006c 100644 --- a/tests/unit/epub-location-controller.vitest.spec.ts +++ b/tests/unit/epub-location-controller.vitest.spec.ts @@ -4,7 +4,7 @@ import { isDirectionalEpubLocation, shouldNavigateToDifferentCfi, shouldPersistEpubLocation, -} from '../../src/hooks/epub/useEPUBLocationController'; +} from '../../src/lib/client/epub/location-controller'; describe('EPUB location controller helpers', () => { test('detects directional locations', () => { diff --git a/tests/unit/onboarding-flow.vitest.spec.ts b/tests/unit/onboarding-flow.vitest.spec.ts index 8569171..a51edaa 100644 --- a/tests/unit/onboarding-flow.vitest.spec.ts +++ b/tests/unit/onboarding-flow.vitest.spec.ts @@ -9,7 +9,6 @@ describe('onboarding flow resolver', () => { privacyAccepted: false, claimEligible: true, claimHasData: true, - migrationRequired: true, changelogPending: true, }); @@ -22,33 +21,18 @@ describe('onboarding flow resolver', () => { privacyAccepted: true, claimEligible: true, claimHasData: true, - migrationRequired: true, changelogPending: true, }); expect(step).toBe('claim'); }); - test('resolves migration when no claim is needed', () => { - const step = resolveNextOnboardingStep({ - privacyRequired: true, - privacyAccepted: true, - claimEligible: true, - claimHasData: false, - migrationRequired: true, - changelogPending: true, - }); - - expect(step).toBe('migration'); - }); - test('resolves changelog when prior steps are clear', () => { const step = resolveNextOnboardingStep({ privacyRequired: true, privacyAccepted: true, claimEligible: true, claimHasData: false, - migrationRequired: false, changelogPending: true, }); @@ -61,7 +45,6 @@ describe('onboarding flow resolver', () => { privacyAccepted: true, claimEligible: true, claimHasData: false, - migrationRequired: false, changelogPending: false, }); const minimalStep = resolveNextOnboardingStep({ @@ -69,7 +52,6 @@ describe('onboarding flow resolver', () => { privacyAccepted: false, claimEligible: false, claimHasData: false, - migrationRequired: false, changelogPending: false, }); diff --git a/tests/unit/onboarding-state.vitest.spec.ts b/tests/unit/onboarding-state.vitest.spec.ts deleted file mode 100644 index c457b92..0000000 --- a/tests/unit/onboarding-state.vitest.spec.ts +++ /dev/null @@ -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(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); - }); -}); - diff --git a/tests/unit/query-keys.vitest.spec.ts b/tests/unit/query-keys.vitest.spec.ts new file mode 100644 index 0000000..4c80508 --- /dev/null +++ b/tests/unit/query-keys.vitest.spec.ts @@ -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']); + }); +}); diff --git a/tests/unit/transfer-user-documents.vitest.spec.ts b/tests/unit/transfer-user-documents.vitest.spec.ts index 11c93d7..ad723d1 100644 --- a/tests/unit/transfer-user-documents.vitest.spec.ts +++ b/tests/unit/transfer-user-documents.vitest.spec.ts @@ -25,6 +25,8 @@ describe('transferUserDocuments', () => { size INTEGER NOT NULL, last_modified INTEGER NOT NULL, file_path TEXT NOT NULL, + folder_id TEXT, + recently_opened_at INTEGER, parse_state TEXT, parsed_json_key TEXT, created_at INTEGER, @@ -149,6 +151,8 @@ describe('transferUserDocuments', () => { size INTEGER NOT NULL, last_modified INTEGER NOT NULL, file_path TEXT NOT NULL, + folder_id TEXT, + recently_opened_at INTEGER, parse_state TEXT, parsed_json_key TEXT, created_at INTEGER, diff --git a/tests/unit/tts-provider-catalog.vitest.spec.ts b/tests/unit/tts-provider-catalog.vitest.spec.ts index 3725bee..e848fed 100644 --- a/tests/unit/tts-provider-catalog.vitest.spec.ts +++ b/tests/unit/tts-provider-catalog.vitest.spec.ts @@ -533,7 +533,6 @@ describe('config helpers', () => { test('builds synced preference patches and honors non-default filtering', () => { expect(buildSyncedPreferencePatch({ voiceSpeed: 1.2, - baseUrl: 'http://localhost', ttsModel: 'kokoro', })).toEqual({ voiceSpeed: 1.2, diff --git a/tests/unit/user-preferences-sync.vitest.spec.ts b/tests/unit/user-preferences-sync.vitest.spec.ts deleted file mode 100644 index aae3c76..0000000 --- a/tests/unit/user-preferences-sync.vitest.spec.ts +++ /dev/null @@ -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 & { 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(); - }); -}); diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts index dad3ae0..e2701e1 100644 --- a/tests/upload.spec.ts +++ b/tests/upload.spec.ts @@ -1,14 +1,9 @@ import { test, expect } from '@playwright/test'; import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers'; -interface HtmlDocumentRow { - id?: string; - data?: string; -} - type HashCheckResult = | { ok: true; storedId: string; computedId: string } - | { ok: false; reason: 'Missing stored html document' | 'Hash mismatch'; storedId?: string; computedId?: string }; + | { ok: false; reason: 'Missing stored html document' | 'Hash mismatch' | 'Content fetch failed'; storedId?: string; computedId?: string }; test.describe('Document Upload Tests', () => { test.beforeEach(async ({ page }, testInfo) => { @@ -58,38 +53,24 @@ test.describe('Document Upload Tests', () => { await expectDocumentListed(page, 'sample.txt'); const result = await page.evaluate(async () => { - const idb = await new Promise((resolve, reject) => { - const request = indexedDB.open('openreader-db'); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); + const listRes = await fetch('/api/documents', { cache: 'no-store' }); + const docs = listRes.ok + ? ((await listRes.json()) as { documents?: Array<{ id: string; name: string }> }).documents ?? [] + : []; + const doc = docs.find((item) => item.name === 'sample.txt'); + if (!doc?.id) return { ok: false, reason: 'Missing stored html document' as const }; - try { - const docs = await new Promise((resolve, reject) => { - const tx = idb.transaction('html-documents', 'readonly'); - const store = tx.objectStore('html-documents'); - const request = store.getAll(); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result as HtmlDocumentRow[]); - }); + const contentRes = await fetch(`/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`, { cache: 'no-store' }); + if (!contentRes.ok) return { ok: false, reason: 'Content fetch failed' as const, storedId: doc.id }; + const digest = await crypto.subtle.digest('SHA-256', await contentRes.arrayBuffer()); + const computedId = Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); - if (!docs[0]?.data || !docs[0]?.id) { - return { ok: false, reason: 'Missing stored html document' as const }; - } - - const bytes = new TextEncoder().encode(String(docs[0].data)); - const digest = await crypto.subtle.digest('SHA-256', bytes); - const computedId = Array.from(new Uint8Array(digest)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - - if (computedId === docs[0].id) { - return { ok: true as const, storedId: docs[0].id as string, computedId }; - } - return { ok: false as const, reason: 'Hash mismatch', storedId: docs[0].id as string, computedId }; - } finally { - idb.close(); + if (computedId === doc.id) { + return { ok: true as const, storedId: doc.id, computedId }; } + return { ok: false as const, reason: 'Hash mismatch', storedId: doc.id, computedId }; }); const detail = result.ok