diff --git a/.env.example b/.env.example index 4418f04..8115116 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,17 @@ IMPORT_LIBRARY_DIRS= # (Required without Docker) Path to your local whisper.cpp CLI binary for STT timestamp generation WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli +# Heavy compute backend mode for whisper alignment + PDF layout parsing. +# local = run compute in-process (default) +# none = disable both capabilities (good for preview/serverless) +# worker = reserved for future external worker mode (not implemented in v1) +OPENREADER_COMPUTE_MODE=local +# OPENREADER_COMPUTE_WORKER_URL= +# OPENREADER_COMPUTE_WORKER_TOKEN= + +# Optional override for Docling layout model download URL +# OPENREADER_DOCLING_MODEL_URL=https://huggingface.co/ds4sd/docling-layout-heron/resolve/main/model.onnx + # (Optional) Override ffmpeg binary path used for audiobook processing FFMPEG_BIN= @@ -95,4 +106,3 @@ FFMPEG_BIN= # NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai # NEXT_PUBLIC_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json # NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true -# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true diff --git a/Dockerfile b/Dockerfile index 30c4ace..a4d6c81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git && \ cd whisper.cpp && \ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF $( [ "$TARGETARCH" = "arm64" ] && echo "-DGGML_CPU_ARM_ARCH=armv8-a" || true ) && \ cmake --build build -j +RUN wget -qO /tmp/whisper.cpp-LICENSE.txt "https://raw.githubusercontent.com/ggml-org/whisper.cpp/master/LICENSE" # Stage 1b: extract seaweedfs weed binary (for optional embedded weed mini) # Pin to 4.18 because CI observed upload regressions on 4.19. @@ -71,10 +72,13 @@ COPY --from=app-builder /app ./ COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses # Include SeaweedFS license text for the copied weed binary. COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt +# Include static model notices for runtime-downloaded assets. +COPY src/lib/server/pdf-layout/model/LICENSE.txt /licenses/docling-layout-LICENSE.txt # Copy the compiled whisper.cpp build output into the runtime image # (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so) COPY --from=whisper-builder /opt/whisper.cpp/build /opt/whisper.cpp/build +COPY --from=whisper-builder /tmp/whisper.cpp-LICENSE.txt /licenses/whisper.cpp-LICENSE.txt # Copy seaweedfs weed binary for optional embedded local S3. COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed RUN chmod +x /usr/local/bin/weed diff --git a/README.md b/README.md index f233826..22e0590 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader - 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra). - 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. -- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps. +- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps (`OPENREADER_COMPUTE_MODE=local` + `WHISPER_CPP_BIN`). +- 🧱 **Layout-aware PDF parsing** (Docling ONNX) with structured blocks for cleaner TTS/chaptering. - 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. - 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends. - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 533d500..e00d207 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -74,11 +74,12 @@ Runtime-editable settings, one row per key: | `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. | | `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. | | `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). | -| `enableWordHighlight` | Enable whisper.cpp word-by-word highlighting during TTS playback. | | `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. | | `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). | | `enableDestructiveDeleteActions` | Show "Delete all data" buttons in the Documents tab (auth-disabled mode). | +Word-by-word highlighting and PDF layout parsing capability are controlled by `OPENREADER_COMPUTE_MODE` (server env), not an admin runtime flag. + Each row shows a source badge: - **from env** — the value was migrated from the corresponding `NEXT_PUBLIC_*` env var on first boot. Editing it in the UI flips the source to **admin**. diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index 630a29e..643823d 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -31,7 +31,7 @@ ADMIN_EMAILS=alice@example.com,bob@example.com Admins see a new **Admin** tab in **Settings** with two sub-tabs: - **Shared TTS providers** — server-managed TTS provider instances with encrypted keys, visible to all users. -- **Site features** — runtime overrides for what were previously `NEXT_PUBLIC_*` build-time flags (including account signup availability, default TTS provider/model, word highlighting, audiobook export, etc.). +- **Site features** — runtime overrides for what were previously `NEXT_PUBLIC_*` build-time flags (including account signup availability, default TTS provider/model, audiobook export, etc.). Admin assignment is reconciled on every session resolution, so removing an email from `ADMIN_EMAILS` demotes the user on next login without a restart. See [Admin Panel](./admin-panel) for the full reference. diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 7fc484a..93d144f 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -156,7 +156,7 @@ If you are not on Debian/Ubuntu, install equivalent packages with your distro pa - Arch: use `pacman` (`base-devel cmake curl git tar xz`) :::tip -Set `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting. +Set `OPENREADER_COMPUTE_MODE=local` and `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting. ::: @@ -194,6 +194,7 @@ Use one of these `.env` mode templates: ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none +OPENREADER_COMPUTE_MODE=local # Leave BASE_URL and AUTH_SECRET unset to keep auth disabled. # (Admin panel is unavailable without auth.) # API_BASE/API_KEY seed a shared default provider if you want shared mode. @@ -205,6 +206,7 @@ API_KEY=none ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none +OPENREADER_COMPUTE_MODE=local BASE_URL=http://localhost:3003 AUTH_SECRET= # Optional when you need multiple local origins: @@ -219,6 +221,7 @@ AUTH_SECRET= # on first boot, then no longer read. Manage them in Settings → Admin afterwards. API_BASE=http://host.docker.internal:8880/v1 API_KEY=none +OPENREADER_COMPUTE_MODE=local BASE_URL=http://localhost:3003 AUTH_SECRET= # Comma-separated emails to auto-promote to admin on signin. @@ -231,6 +234,7 @@ ADMIN_EMAILS=you@example.com ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none +OPENREADER_COMPUTE_MODE=local USE_EMBEDDED_WEED_MINI=false S3_BUCKET=your-bucket S3_REGION=us-east-1 @@ -256,6 +260,10 @@ If you want each user to enter personal provider credentials, set `restrictUserA For all environment variables, see [Environment Variables](../reference/environment-variables). ::: +:::tip Optional model prefetch +To pre-populate the Docling ONNX model cache before first PDF parse, run `pnpm fetch-models`. +::: + See [Auth](../configure/auth) for app/auth behavior. See [Admin Panel](../configure/admin-panel) for the shared-provider and feature-flag management UI. Storage configuration details are in [Object / Blob Storage](../configure/object-blob-storage). diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 88adbee..1f6f2e0 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -35,6 +35,11 @@ BASE_URL=https://your-app.vercel.app AUTH_SECRET=... ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app +# Heavy compute (recommended on Vercel in v1) +# local = requires native binaries/models in-process +# none = disable whisper alignment + PDF layout parsing +OPENREADER_COMPUTE_MODE=none + # First-boot seed for the TTS shared provider (optional; manage in-app afterwards) API_KEY=your_replicate_key # API_BASE only needed for OpenAI-compatible self-hosted providers @@ -58,7 +63,6 @@ After the first successful deploy and admin login, open **Settings → Admin** a - `defaultTtsProvider=replicate` (or your preferred shared slug). - `showAllProviderModels=false` if you want users locked to each provider's default model. - `enableAudiobookExport=true`. - - `enableWordHighlight=false` unless your timestamp stack is configured. ## 3. Legacy first-boot seed (optional) @@ -126,4 +130,4 @@ Adjust memory per route if your files are larger or your plan differs. 1. Upload and read a PDF/EPUB document. 2. Confirm sync/blob fetch works across refreshes/devices. 3. Generate at least one audiobook chapter and play/download it. -4. If using word highlighting, verify timestamps are produced and rendered. +4. If you later enable compute locally (`OPENREADER_COMPUTE_MODE=local`), verify word highlighting timestamps on a TTS run. diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index ef79788..e023ba8 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -53,7 +53,11 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | -| `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps | +| `OPENREADER_COMPUTE_MODE` | Heavy compute backend | `local` | Set to `none` to disable whisper alignment + PDF layout parsing | +| `OPENREADER_COMPUTE_WORKER_URL` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | +| `OPENREADER_COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset | Reserved for future worker backend mode (`worker`) | +| `OPENREADER_DOCLING_MODEL_URL` | PDF layout model | Docling HF URL | Override ONNX download URL for `ensureModel()` | +| `WHISPER_CPP_BIN` | Word timing (local mode) | unset | Set to enable `whisper.cpp` timestamps in `OPENREADER_COMPUTE_MODE=local` | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | @@ -346,6 +350,38 @@ Multiple library roots for server library import. ## Audio Tooling and Alignment +### OPENREADER_COMPUTE_MODE + +Selects the backend for heavy compute features (word alignment + PDF layout parsing). + +- Default: `local` +- Supported in v1: + - `local`: run compute in-process on the app server + - `none`: disable these features cleanly +- `worker` is reserved for a future external worker backend and currently fails fast at startup if selected + +### OPENREADER_COMPUTE_WORKER_URL + +Reserved for future external compute worker mode. + +- Used only when `OPENREADER_COMPUTE_MODE=worker` (not implemented in v1) +- Leave unset in v1 + +### OPENREADER_COMPUTE_WORKER_TOKEN + +Reserved bearer token for future external compute worker mode. + +- Used only when `OPENREADER_COMPUTE_MODE=worker` (not implemented in v1) +- Leave unset in v1 + +### OPENREADER_DOCLING_MODEL_URL + +Override URL for the Docling ONNX layout model downloaded by `ensureModel()`. + +- Default: `https://huggingface.co/ds4sd/docling-layout-heron/resolve/main/model.onnx` +- Optional for custom mirrors/air-gapped workflows +- You can also pre-populate the model cache via `pnpm fetch-models` + ### WHISPER_CPP_BIN Absolute path to compiled `whisper.cpp` binary for word-level timestamps. @@ -432,14 +468,3 @@ Controls whether audiobook export UI/actions are shown in the client. - Default: `true` (enabled) - Affects export entry points in PDF/EPUB pages and document settings UI - Runtime key: `enableAudiobookExport` - -### NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT - -Controls word-by-word highlighting UI and timestamp-alignment behavior. - -- Default: `true` (enabled) -- Requires working timestamp generation (for example `WHISPER_CPP_BIN`) -- Affects: - - Word-highlight toggles in document settings - - Alignment requests during TTS playback -- Runtime key: `enableWordHighlight` diff --git a/drizzle/postgres/0005_pdf_layout_and_compute.sql b/drizzle/postgres/0005_pdf_layout_and_compute.sql new file mode 100644 index 0000000..58654dd --- /dev/null +++ b/drizzle/postgres/0005_pdf_layout_and_compute.sql @@ -0,0 +1,14 @@ +CREATE TABLE "document_settings" ( + "document_id" text NOT NULL, + "user_id" text NOT NULL, + "data_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "client_updated_at_ms" bigint 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 "document_settings_document_id_user_id_pk" PRIMARY KEY("document_id","user_id") +); +--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN "parse_status" text;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN "parsed_json_key" text;--> statement-breakpoint +ALTER TABLE "document_settings" ADD CONSTRAINT "document_settings_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_document_settings_user_id" ON "document_settings" USING btree ("user_id"); diff --git a/drizzle/postgres/meta/0005_snapshot.json b/drizzle/postgres/meta/0005_snapshot.json new file mode 100644 index 0000000..9d083c1 --- /dev/null +++ b/drizzle/postgres/meta/0005_snapshot.json @@ -0,0 +1,1833 @@ +{ + "id": "a23bc220-ebf2-43e2-92b6-d765c23c07d4", + "prevId": "abda257a-d6bd-4a44-b069-4ca884890e9e", + "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_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, + "default": "'card-240-jpeg'" + }, + "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, + "default": 240 + }, + "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 + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "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": {} + } + }, + "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" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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_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/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 266bd66..035979f 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1778644075378, "tag": "0004_admin_panel", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1779041718214, + "tag": "0005_pdf_layout_and_compute", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0005_pdf_layout_and_compute.sql b/drizzle/sqlite/0005_pdf_layout_and_compute.sql new file mode 100644 index 0000000..8c6f338 --- /dev/null +++ b/drizzle/sqlite/0005_pdf_layout_and_compute.sql @@ -0,0 +1,14 @@ +CREATE TABLE `document_settings` ( + `document_id` text NOT NULL, + `user_id` text NOT NULL, + `data_json` text DEFAULT '{}' NOT NULL, + `client_updated_at_ms` 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(`document_id`, `user_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_document_settings_user_id` ON `document_settings` (`user_id`);--> statement-breakpoint +ALTER TABLE `documents` ADD `parse_status` text;--> statement-breakpoint +ALTER TABLE `documents` ADD `parsed_json_key` text; diff --git a/drizzle/sqlite/meta/0005_snapshot.json b/drizzle/sqlite/meta/0005_snapshot.json new file mode 100644 index 0000000..01640f2 --- /dev/null +++ b/drizzle/sqlite/meta/0005_snapshot.json @@ -0,0 +1,1695 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bb7fa58d-9b33-46a5-81d1-d2cdfb88c8ee", + "prevId": "7465ec8f-ee2c-4bc3-a0ce-eb8c145d5c6c", + "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_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, + "default": "'card-240-jpeg'" + }, + "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, + "default": 240 + }, + "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 + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "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))" + } + }, + "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 + } + }, + "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" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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_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/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index fafa753..3f75e71 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1778644075081, "tag": "0004_admin_panel", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1779041717890, + "tag": "0005_pdf_layout_and_compute", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index bd5aeaf..d7fec12 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "migrate-fs": "node scripts/migrate-fs-v2.mjs", "migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true", "generate": "node drizzle/scripts/generate.mjs", + "fetch-models": "node scripts/fetch-models.mjs", "docs:init": "pnpm --dir docs-site install", "docs:dev": "pnpm --dir docs-site start", "docs:build": "pnpm --dir docs-site build", @@ -48,6 +49,7 @@ "jszip": "^3.10.1", "lru-cache": "^11.3.6", "next": "^15.5.18", + "onnxruntime-node": "^1.26.0", "openai": "^6.37.0", "pdfjs-dist": "4.8.69", "pg": "^8.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f76116..c96e8fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: next: specifier: ^15.5.18 version: 15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + onnxruntime-node: + specifier: ^1.26.0 + version: 1.26.0 openai: specifier: ^6.37.0 version: 6.37.0(zod@4.4.3) @@ -1854,6 +1857,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.17: + resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} + engines: {node: '>=12.0'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -2843,6 +2850,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + global-agent@4.1.3: + resolution: {integrity: sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==} + engines: {node: '>=10.0'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3206,6 +3217,10 @@ packages: marks-pane@1.0.9: resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==} + matcher@4.0.0: + resolution: {integrity: sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3488,6 +3503,13 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onnxruntime-common@1.26.0: + resolution: {integrity: sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==} + + onnxruntime-node@1.26.0: + resolution: {integrity: sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==} + os: [win32, darwin, linux] + openai@6.37.0: resolution: {integrity: sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==} hasBin: true @@ -3924,6 +3946,10 @@ packages: engines: {node: '>=10'} hasBin: true + serialize-error@8.1.0: + resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} + engines: {node: '>=10'} + set-cookie-parser@3.1.0: resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} @@ -4181,6 +4207,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} @@ -5957,6 +5987,8 @@ snapshots: acorn@8.16.0: {} + adm-zip@0.5.17: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -7097,6 +7129,13 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + global-agent@4.1.3: + dependencies: + globalthis: 1.0.4 + matcher: 4.0.0 + semver: 7.8.0 + serialize-error: 8.1.0 + globals@14.0.0: {} globalthis@1.0.4: @@ -7457,6 +7496,10 @@ snapshots: marks-pane@1.0.9: {} + matcher@4.0.0: + dependencies: + escape-string-regexp: 4.0.0 + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -7946,6 +7989,14 @@ snapshots: dependencies: wrappy: 1.0.2 + onnxruntime-common@1.26.0: {} + + onnxruntime-node@1.26.0: + dependencies: + adm-zip: 0.5.17 + global-agent: 4.1.3 + onnxruntime-common: 1.26.0 + openai@6.37.0(zod@4.4.3): optionalDependencies: zod: 4.4.3 @@ -8439,6 +8490,10 @@ snapshots: semver@7.8.0: {} + serialize-error@8.1.0: + dependencies: + type-fest: 0.20.2 + set-cookie-parser@3.1.0: {} set-function-length@1.2.2: @@ -8814,6 +8869,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.20.2: {} + type@2.7.3: {} typed-array-buffer@1.0.3: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 225f824..caae007 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,6 @@ packages: - . -strictDepBuilds: false - allowBuilds: '@napi-rs/canvas': true better-sqlite3: true @@ -11,10 +9,13 @@ allowBuilds: es5-ext: false esbuild: false ffmpeg-static: true + onnxruntime-node: true sharp: false unrs-resolver: false overrides: - lodash: ^4.17.23 - '@xmldom/xmldom': ^0.9.8 '@types/localforage': npm:empty-module@0.0.2 + '@xmldom/xmldom': ^0.9.8 + lodash: ^4.17.23 + +strictDepBuilds: false diff --git a/scripts/fetch-models.mjs b/scripts/fetch-models.mjs new file mode 100644 index 0000000..24cd5ac --- /dev/null +++ b/scripts/fetch-models.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const modelDir = path.join(process.cwd(), 'docstore', 'model'); +const modelPath = path.join(modelDir, 'docling-layout-heron.onnx'); +const configPath = path.join(modelDir, 'docling-layout-heron.config.json'); +const licensePath = path.join(modelDir, 'docling-layout-heron.LICENSE.txt'); +const staticLicensePath = path.join(process.cwd(), 'src', 'lib', 'server', 'pdf-layout', 'model', 'LICENSE.txt'); + +const modelUrl = process.env.OPENREADER_DOCLING_MODEL_URL || 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/model.onnx'; +const configUrl = process.env.OPENREADER_DOCLING_CONFIG_URL || 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/config.json'; + +await mkdir(modelDir, { recursive: true }); + +const modelRes = await fetch(modelUrl); +if (!modelRes.ok) { + throw new Error(`Failed to fetch model: ${modelRes.status} ${modelRes.statusText}`); +} +await writeFile(modelPath, new Uint8Array(await modelRes.arrayBuffer())); + +const configRes = await fetch(configUrl); +if (!configRes.ok) { + throw new Error(`Failed to fetch config: ${configRes.status} ${configRes.statusText}`); +} +await writeFile(configPath, new Uint8Array(await configRes.arrayBuffer())); + +const staticLicense = await import('node:fs/promises').then((m) => m.readFile(staticLicensePath)); +await writeFile(licensePath, staticLicense); + +console.log(`Saved model to ${modelPath}`); diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index 1ecd6aa..c11441b 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -32,6 +32,7 @@ const PDFViewer = dynamic( export default function PDFViewerPage() { const canExportAudiobook = useFeatureFlag('enableAudiobookExport'); + const computeAvailable = useFeatureFlag('computeAvailable'); const { id } = useParams(); const router = useRouter(); const pdfState = usePdfDocument(); @@ -41,6 +42,12 @@ export default function PDFViewerPage() { clearCurrDoc, currDocPage, currDocPages, + parseStatus, + documentSettings, + updateDocumentSettings, + parsedOverlayEnabled, + setParsedOverlayEnabled, + forceReparseParsedPdf, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter, } = pdfState; @@ -232,6 +239,40 @@ export default function PDFViewerPage() { setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} + pdf={{ + computeAvailable, + parseStatus, + parsedOverlayEnabled, + skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], + chaptersFromSections: documentSettings.pdf?.chaptersFromSections ?? true, + onToggleOverlay: (enabled) => setParsedOverlayEnabled(enabled), + onToggleSkipKind: (kind, enabled) => { + const current = new Set(documentSettings.pdf?.skipBlockKinds ?? []); + if (enabled) current.add(kind); + else current.delete(kind); + void updateDocumentSettings({ + ...documentSettings, + schemaVersion: 1, + pdf: { + ...(documentSettings.pdf ?? { chaptersFromSections: true }), + skipBlockKinds: Array.from(current), + chaptersFromSections: documentSettings.pdf?.chaptersFromSections ?? true, + }, + }); + }, + onToggleChaptersFromSections: (enabled) => { + void updateDocumentSettings({ + ...documentSettings, + schemaVersion: 1, + pdf: { + ...(documentSettings.pdf ?? { skipBlockKinds: [] }), + skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], + chaptersFromSections: enabled, + }, + }); + }, + onForceReparse: () => forceReparseParsedPdf(), + }} /> Promise; + parsedOverlayEnabled: boolean; + setParsedOverlayEnabled: (enabled: boolean) => void; + forceReparseParsedPdf: () => Promise; setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; @@ -119,18 +139,24 @@ export function usePdfDocument(): PdfDocumentState { const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); const [pdfDocument, setPdfDocument] = useState(); + const [parsedDocument, setParsedDocument] = useState(null); + const [parseStatus, setParseStatus] = useState(null); + const [documentSettings, setDocumentSettings] = useState(DEFAULT_DOCUMENT_SETTINGS); + const [parsedOverlayEnabled, setParsedOverlayEnabled] = useState(false); const [isAudioCombining] = useState(false); const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({ pdfDocument, + parsed: parsedDocument ?? undefined, + settings: documentSettings, margins: { - header: headerMargin, - footer: footerMargin, - left: leftMargin, - right: rightMargin, + header: documentSettings.pdf?.margins?.header ?? headerMargin, + footer: documentSettings.pdf?.margins?.footer ?? footerMargin, + left: documentSettings.pdf?.margins?.left ?? leftMargin, + right: documentSettings.pdf?.margins?.right ?? rightMargin, }, smartSentenceSplitting, maxBlockLength: ttsSegmentMaxBlockLength, - }), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]); + }), [pdfDocument, parsedDocument, documentSettings, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]); const pageTextCacheRef = useRef>(new Map()); const [currDocPage, setCurrDocPage] = useState(currDocPageNumber); @@ -144,6 +170,66 @@ export function usePdfDocument(): PdfDocumentState { // Guards for setCurrentDocument to prevent stale loads from overwriting newer selections. const docLoadSeqRef = useRef(0); const docLoadAbortRef = useRef(null); + const parsePollAbortRef = useRef(null); + + const fetchParsedDocument = useCallback(async ( + documentId: string, + initialStatus: PdfParseStatus | null, + signal: AbortSignal, + ): Promise => { + // Legacy PDFs may have null parseStatus; treat as pending so opening the + // document backfills parse output via the parsed endpoint polling path. + const effectiveInitialStatus: PdfParseStatus = initialStatus ?? 'pending'; + setParseStatus(effectiveInitialStatus); + if (effectiveInitialStatus === 'unsupported') { + setParsedDocument(null); + return; + } + + const maxAttempts = 25; + const delayMs = 1200; + const retryFailed = effectiveInitialStatus === 'failed'; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (signal.aborted) return; + const result = await getParsedPdfDocument(documentId, { + signal, + retryFailed: retryFailed && attempt === 0, + }); + if (result.status === 'ready') { + setParsedDocument(result.parsed); + setParseStatus('ready'); + return; + } + setParseStatus(result.status); + if (result.status === 'failed' || result.status === 'unsupported') { + setParsedDocument(null); + return; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + }, []); + + 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 startParsedPolling = useCallback((documentId: string, initialStatus: PdfParseStatus | null) => { + parsePollAbortRef.current?.abort(); + const controller = new AbortController(); + parsePollAbortRef.current = controller; + void fetchParsedDocument(documentId, initialStatus, controller.signal).finally(() => { + if (parsePollAbortRef.current === controller) { + parsePollAbortRef.current = null; + } + }); + }, [fetchParsedDocument]); useEffect(() => { pdfDocumentRef.current = pdfDocument; @@ -189,10 +275,37 @@ export function usePdfDocument(): PdfDocumentState { : null; const margins = { - header: headerMargin, - footer: footerMargin, - left: leftMargin, - right: rightMargin + header: documentSettings.pdf?.margins?.header ?? headerMargin, + footer: documentSettings.pdf?.margins?.footer ?? footerMargin, + left: documentSettings.pdf?.margins?.left ?? leftMargin, + right: documentSettings.pdf?.margins?.right ?? rightMargin + }; + + const pageFromParsed = (pageNum: number): ParsedPdfPage | undefined => + parsedDocument?.pages.find((page) => page.pageNumber === pageNum); + + const hasUsableParsedBlocks = (page: ParsedPdfPage | undefined): page is ParsedPdfPage => { + if (!page) return false; + const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []); + return page.blocks.some((block) => !skipKinds.has(block.kind) && block.text.trim().length > 0); + }; + + const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => { + const page = pageFromParsed(pageNum); + if (!page) return []; + const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []); + return page.blocks + .filter((block) => !skipKinds.has(block.kind)) + .map((block) => ({ + sourceKey: `pdf:${pageNum}:${block.id}`, + text: block.text, + locator: { + readerType: 'pdf', + page: block.fragments[0]?.page ?? pageNum, + blockId: block.id, + } as TTSSegmentLocator, + })) + .filter((unit) => unit.text.trim().length > 0); }; const getPageText = async (pageNumber: number, shouldCache = false): Promise => { @@ -209,7 +322,15 @@ export function usePdfDocument(): PdfDocumentState { return cached; } - const extracted = await extractTextFromPDF(currentPdf, pageNumber, margins); + const parsedPage = pageFromParsed(pageNumber); + const useParsedPage = hasUsableParsedBlocks(parsedPage) ? parsedPage : undefined; + const extracted = await extractTextFromPDF( + currentPdf, + pageNumber, + margins, + useParsedPage, + useParsedPage ? documentSettings.pdf?.skipBlockKinds : undefined, + ); if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { throw new DOMException('Stale PDF extraction', 'AbortError'); @@ -281,12 +402,17 @@ export function usePdfDocument(): PdfDocumentState { if (text !== currDocText || text === '') { setCurrDocText(text); + const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber); + const useBlockStructuredPlanning = sourceUnits.length > 0; setTTSText(text, { location: currDocPageNumber, - previousText: prevText, - nextLocation: nextPageNumber, - nextText: nextText, - upcomingLocations: additionalUpcoming, + ...(!useBlockStructuredPlanning ? { + previousText: prevText, + nextLocation: nextPageNumber, + nextText: nextText, + upcomingLocations: additionalUpcoming, + } : {}), + ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); } } catch (error) { @@ -305,6 +431,8 @@ export function usePdfDocument(): PdfDocumentState { leftMargin, rightMargin, segmentPreloadDepthPages, + parsedDocument, + documentSettings, ]); /** @@ -341,6 +469,8 @@ export function usePdfDocument(): PdfDocumentState { clearTimeout(emptyRetryRef.current.timer); } emptyRetryRef.current = null; + parsePollAbortRef.current?.abort(); + parsePollAbortRef.current = null; pageTextCacheRef.current.clear(); setPdfDocument(undefined); setCurrDocPages(undefined); @@ -348,6 +478,9 @@ export function usePdfDocument(): PdfDocumentState { setCurrDocId(id); setCurrDocName(undefined); setCurrDocData(undefined); + setParsedDocument(null); + setParseStatus(null); + setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); const meta = await getDocumentMetadata(id, { signal: controller.signal }); if (seq !== docLoadSeqRef.current) return; // stale @@ -355,6 +488,10 @@ export function usePdfDocument(): PdfDocumentState { console.error('Document not found on server'); return; } + if (meta.type === 'pdf') { + startParsedPolling(id, (meta.parseStatus ?? null) as PdfParseStatus | null); + void fetchDocumentSettings(id, controller.signal); + } const doc = await ensureCachedDocument(meta, { signal: controller.signal }); if (seq !== docLoadSeqRef.current) return; // stale @@ -376,7 +513,39 @@ export function usePdfDocument(): PdfDocumentState { docLoadAbortRef.current = null; } } - }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]); + }, [ + setCurrDocId, + setCurrDocName, + setCurrDocData, + setCurrDocPages, + setCurrDocText, + setPdfDocument, + fetchDocumentSettings, + startParsedPolling, + ]); + + const updateDocumentSettings = useCallback(async (settings: DocumentSettings): Promise => { + if (!currDocId) return; + setDocumentSettings(settings); + try { + const updated = await putDocumentSettings(currDocId, settings); + setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, updated.settings)); + } catch (error) { + console.warn('Failed to persist document settings:', error); + } + }, [currDocId]); + + const forceReparseParsedPdf = useCallback(async (): Promise => { + if (!currDocId) return; + try { + await forceReparsePdfDocument(currDocId); + setParsedDocument(null); + setParseStatus('pending'); + startParsedPolling(currDocId, 'pending'); + } catch (error) { + console.error('Failed to force PDF reparse:', error); + } + }, [currDocId, startParsedPolling]); /** * Clears the current document state @@ -390,6 +559,8 @@ export function usePdfDocument(): PdfDocumentState { docLoadSeqRef.current += 1; docLoadAbortRef.current?.abort(); docLoadAbortRef.current = null; + parsePollAbortRef.current?.abort(); + parsePollAbortRef.current = null; if (emptyRetryRef.current?.timer) { clearTimeout(emptyRetryRef.current.timer); } @@ -400,6 +571,9 @@ export function usePdfDocument(): PdfDocumentState { setCurrDocText(undefined); setCurrDocPages(undefined); setPdfDocument(undefined); + setParsedDocument(null); + setParseStatus(null); + setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); pageTextCacheRef.current.clear(); stop(); }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]); @@ -499,6 +673,13 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, currDocPage, currDocText, + parsedDocument, + parseStatus, + documentSettings, + updateDocumentSettings, + parsedOverlayEnabled, + setParsedOverlayEnabled, + forceReparseParsedPdf, clearCurrDoc, highlightPattern, clearHighlights, @@ -518,6 +699,13 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, currDocPage, currDocText, + parsedDocument, + parseStatus, + documentSettings, + updateDocumentSettings, + parsedOverlayEnabled, + setParsedOverlayEnabled, + forceReparseParsedPdf, clearCurrDoc, pdfDocument, createFullAudioBook, diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts new file mode 100644 index 0000000..96e281c --- /dev/null +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { getParsedDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { enqueueParsePdfJob } from '@/lib/server/jobs/parsePdfJob'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import type { ParsedPdfDocument } from '@/types/parsed-pdf'; + +export const dynamic = 'force-dynamic'; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function hasAnyParsedBlocks(doc: ParsedPdfDocument | null): boolean { + if (!doc || !Array.isArray(doc.pages)) return false; + return doc.pages.some((page) => Array.isArray(page.blocks) && page.blocks.length > 0); +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const params = await ctx.params; + const id = (params.id || '').trim().toLowerCase(); + const retryFailed = req.nextUrl.searchParams.get('retry') === '1'; + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const rows = (await db + .select({ + id: documents.id, + userId: documents.userId, + parseStatus: documents.parseStatus, + }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + parseStatus: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + }>; + + const row = rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0]; + if (!row) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (row.parseStatus === 'failed' && retryFailed) { + await db + .update(documents) + .set({ parseStatus: 'pending' }) + .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); + enqueueParsePdfJob({ + documentId: id, + userId: row.userId, + namespace: testNamespace, + }); + return NextResponse.json({ parseStatus: 'pending' }, { status: 202 }); + } + + if (row.parseStatus !== 'ready') { + if (row.parseStatus === 'pending' || row.parseStatus === 'running' || row.parseStatus === null) { + enqueueParsePdfJob({ + documentId: id, + userId: row.userId, + namespace: testNamespace, + }); + } + return NextResponse.json({ parseStatus: row.parseStatus ?? 'pending' }, { status: 202 }); + } + + try { + const json = await getParsedDocumentBlob(id, testNamespace); + let parsedDoc: ParsedPdfDocument | null = null; + try { + parsedDoc = JSON.parse(Buffer.from(json).toString('utf8')) as ParsedPdfDocument; + } catch { + parsedDoc = null; + } + + if (!hasAnyParsedBlocks(parsedDoc)) { + await db + .update(documents) + .set({ parseStatus: 'pending' }) + .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); + enqueueParsePdfJob({ + documentId: id, + userId: row.userId, + namespace: testNamespace, + }); + return NextResponse.json({ parseStatus: 'pending' }, { status: 202 }); + } + + return new NextResponse(new Uint8Array(json), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + if (isMissingBlobError(error)) { + return NextResponse.json({ parseStatus: 'failed', error: 'Parsed document not found' }, { status: 404 }); + } + throw error; + } + } catch (error) { + console.error('Error reading parsed PDF:', error); + return NextResponse.json({ error: 'Failed to read parsed PDF' }, { status: 500 }); + } +} + +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const params = await ctx.params; + const id = (params.id || '').trim().toLowerCase(); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const rows = (await db + .select({ + id: documents.id, + userId: documents.userId, + parseStatus: documents.parseStatus, + }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + parseStatus: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + }>; + + const row = rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0]; + if (!row) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (row.parseStatus !== 'running') { + await db + .update(documents) + .set({ parseStatus: 'pending' }) + .where(and(eq(documents.id, id), eq(documents.userId, row.userId))); + } + + enqueueParsePdfJob({ + documentId: id, + userId: row.userId, + namespace: testNamespace, + }); + + return NextResponse.json( + { parseStatus: row.parseStatus === 'running' ? 'running' : 'pending' }, + { status: 202 }, + ); + } catch (error) { + console.error('Error forcing parsed PDF refresh:', error); + return NextResponse.json({ error: 'Failed to force parsed PDF refresh' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/[id]/settings/route.ts b/src/app/api/documents/[id]/settings/route.ts new file mode 100644 index 0000000..a63d423 --- /dev/null +++ b/src/app/api/documents/[id]/settings/route.ts @@ -0,0 +1,167 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documentSettings, documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { mergeDocumentSettings } from '@/lib/shared/document-settings'; +import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings'; +import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; + +export const dynamic = 'force-dynamic'; + +function serializeForDb(payload: Record): Record | string { + if (process.env.POSTGRES_URL) return payload; + return JSON.stringify(payload); +} + +function normalizeClientUpdatedAtMs(value: unknown): number { + const normalized = coerceTimestampMs(value, nowTimestampMs()); + if (normalized <= 0) return nowTimestampMs(); + return normalized; +} + +function parseStored(value: unknown): DocumentSettings { + if (typeof value === 'string') { + try { + return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, JSON.parse(value)); + } catch { + return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, null); + } + } + return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, value); +} + +async function resolveDocumentAccess(req: NextRequest, documentId: string): Promise< + | { ownerUserId: string; allowedUserIds: string[] } + | Response +> { + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const rows = await db + .select({ userId: documents.userId }) + .from(documents) + .where(and(eq(documents.id, documentId), inArray(documents.userId, allowedUserIds))) + .limit(1); + + if (!rows[0]) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return { + ownerUserId: rows[0].userId, + allowedUserIds, + }; +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + const { id } = await ctx.params; + const documentId = (id || '').trim().toLowerCase(); + if (!documentId) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const scope = await resolveDocumentAccess(req, documentId); + if (scope instanceof Response) return scope; + + const rows = await db + .select({ + dataJson: documentSettings.dataJson, + clientUpdatedAtMs: documentSettings.clientUpdatedAtMs, + }) + .from(documentSettings) + .where(and( + eq(documentSettings.documentId, documentId), + eq(documentSettings.userId, scope.ownerUserId), + )) + .limit(1); + + const row = rows[0]; + const settings = parseStored(row?.dataJson); + + return NextResponse.json({ + settings, + clientUpdatedAtMs: Number(row?.clientUpdatedAtMs ?? 0), + hasStoredSettings: Boolean(row), + }); + } catch (error) { + console.error('Error loading document settings:', error); + return NextResponse.json({ error: 'Failed to load document settings' }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + const { id } = await ctx.params; + const documentId = (id || '').trim().toLowerCase(); + if (!documentId) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const scope = await resolveDocumentAccess(req, documentId); + if (scope instanceof Response) return scope; + + const body = (await req.json().catch(() => null)) as { settings?: unknown; clientUpdatedAtMs?: unknown } | null; + const incoming = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, body?.settings ?? null); + const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs); + + const existingRows = await db + .select({ + dataJson: documentSettings.dataJson, + clientUpdatedAtMs: documentSettings.clientUpdatedAtMs, + }) + .from(documentSettings) + .where(and( + eq(documentSettings.documentId, documentId), + eq(documentSettings.userId, scope.ownerUserId), + )) + .limit(1); + + const existing = existingRows[0]; + const existingUpdatedAt = Number(existing?.clientUpdatedAtMs ?? 0); + if (existing && clientUpdatedAtMs < existingUpdatedAt) { + return NextResponse.json({ + settings: parseStored(existing.dataJson), + clientUpdatedAtMs: existingUpdatedAt, + applied: false, + }); + } + + const updatedAt = nowTimestampMs(); + const payload = serializeForDb(incoming as unknown as Record); + + await db + .insert(documentSettings) + .values({ + documentId, + userId: scope.ownerUserId, + dataJson: payload as never, + clientUpdatedAtMs, + updatedAt, + }) + .onConflictDoUpdate({ + target: [documentSettings.documentId, documentSettings.userId], + set: { + dataJson: payload as never, + clientUpdatedAtMs, + updatedAt, + }, + }); + + return NextResponse.json({ + settings: incoming, + clientUpdatedAtMs, + applied: true, + }); + } catch (error) { + console.error('Error updating document settings:', error); + return NextResponse.json({ error: 'Failed to update document settings' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 38eb3b9..af1e835 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -9,6 +9,7 @@ import { deleteDocumentPreviewRows, enqueueDocumentPreview, } from '@/lib/server/documents/previews'; +import { enqueueParsePdfJob } from '@/lib/server/jobs/parsePdfJob'; import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; @@ -129,6 +130,8 @@ export async function POST(req: NextRequest) { size: headSize, lastModified: doc.lastModified, filePath: doc.id, + parseStatus: doc.type === 'pdf' ? 'pending' : null, + parsedJsonKey: null, }) .onConflictDoUpdate({ target: [documents.id, documents.userId], @@ -138,6 +141,8 @@ export async function POST(req: NextRequest) { size: headSize, lastModified: doc.lastModified, filePath: doc.id, + parseStatus: doc.type === 'pdf' ? 'pending' : null, + parsedJsonKey: null, }, }); @@ -160,6 +165,14 @@ export async function POST(req: NextRequest) { ).catch((error) => { console.error(`Failed to enqueue preview for document ${doc.id}:`, error); }); + + if (doc.type === 'pdf') { + enqueueParsePdfJob({ + documentId: doc.id, + userId: storageUserId, + namespace: testNamespace, + }); + } } return NextResponse.json({ success: true, stored }); @@ -206,9 +219,25 @@ export async function GET(req: NextRequest) { size: number; lastModified: number; filePath: string; + parseStatus: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + parsedJsonKey: string | null; }>; - const results: BaseDocument[] = rows.map((doc) => { + const preferredById = new Map(); + for (const row of rows) { + const existing = preferredById.get(row.id); + if (!existing) { + preferredById.set(row.id, row); + continue; + } + const isRowPrimary = row.userId === storageUserId; + const isExistingPrimary = existing.userId === storageUserId; + if (isRowPrimary && !isExistingPrimary) { + preferredById.set(row.id, row); + } + } + + const results: BaseDocument[] = Array.from(preferredById.values()).map((doc) => { const type = normalizeDocumentType(doc.type, doc.name); return { id: doc.id, @@ -216,6 +245,8 @@ export async function GET(req: NextRequest) { size: Number(doc.size), lastModified: Number(doc.lastModified), type, + parseStatus: doc.parseStatus, + parsedJsonKey: doc.parsedJsonKey, scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user', }; }); diff --git a/src/app/api/tts/segments/clear/route.ts b/src/app/api/tts/segments/clear/route.ts index 89a07ec..642c62c 100644 --- a/src/app/api/tts/segments/clear/route.ts +++ b/src/app/api/tts/segments/clear/route.ts @@ -1,9 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { and, eq } from 'drizzle-orm'; -import { db } from '@/db'; -import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; -import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; +import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -25,59 +22,16 @@ export async function POST(request: NextRequest) { const scope = await resolveSegmentDocumentScope(request, parsed.documentId); if (scope instanceof Response) return scope; - const rows = (await db - .select({ - segmentId: ttsSegmentVariants.segmentId, - audioKey: ttsSegmentVariants.audioKey, - }) - .from(ttsSegmentVariants) - .innerJoin(ttsSegmentEntries, and( - eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId), - eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId), - )) - .where(and( - eq(ttsSegmentEntries.userId, scope.storageUserId), - eq(ttsSegmentEntries.documentId, parsed.documentId), - eq(ttsSegmentEntries.documentVersion, scope.documentVersion), - ))) as Array<{ segmentId: string; audioKey: string | null }>; - - await db - .delete(ttsSegmentEntries) - .where(and( - eq(ttsSegmentEntries.userId, scope.storageUserId), - eq(ttsSegmentEntries.documentId, parsed.documentId), - eq(ttsSegmentEntries.documentVersion, scope.documentVersion), - )); - - const audioKeys = rows - .map((row) => row.audioKey) - .filter((key): key is string => Boolean(key)); - const uniqueAudioKeys = Array.from(new Set(audioKeys)); - - let deletedAudioObjects = 0; - let warning: string | undefined; - if (uniqueAudioKeys.length > 0) { - try { - deletedAudioObjects = await deleteTtsSegmentAudioObjects(uniqueAudioKeys); - if (deletedAudioObjects < uniqueAudioKeys.length) { - warning = `Deleted ${deletedAudioObjects} of ${uniqueAudioKeys.length} audio objects.`; - } - } catch (error) { - warning = error instanceof Error ? error.message : 'Failed deleting some audio objects'; - console.warn('Failed clearing some TTS segment audio objects:', { - documentId: parsed.documentId, - userId: scope.storageUserId, - error: warning, - }); - } - } + const cleared = await clearTtsSegmentCache({ + userId: scope.storageUserId, + documentId: parsed.documentId, + documentVersion: scope.documentVersion, + readerType: scope.readerType, + }); return NextResponse.json({ documentId: parsed.documentId, - deletedSegments: rows.length, - requestedAudioObjects: uniqueAudioKeys.length, - deletedAudioObjects, - ...(warning ? { warning } : {}), + ...cleared, }); } catch (error) { console.error('Error clearing TTS segment cache:', error); diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index e6e4789..ad5bffa 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -30,7 +30,7 @@ import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem-response'; import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; -import { alignAudioWithText } from '@/lib/server/whisper/alignment'; +import { getCompute } from '@/lib/server/compute'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy'; import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; @@ -374,12 +374,10 @@ export async function POST(request: NextRequest) { try { const audioBuffer = await getTtsSegmentAudioObject(existing.audioKey); const whisperBytes = Uint8Array.from(audioBuffer); - const aligned = await alignAudioWithText( - whisperBytes.buffer, - segment.text, - undefined, - { engine: 'whisper.cpp' }, - ); + const aligned = (await getCompute().alignWords({ + audioBuffer: whisperBytes.buffer, + text: segment.text, + })).alignments; alignment = aligned[0] ? { ...aligned[0], sentenceIndex: segment.original.segmentIndex } : null; if (alignment) { @@ -527,12 +525,10 @@ export async function POST(request: NextRequest) { let alignment: TTSSegmentManifestItem['alignment'] = null; try { const whisperBytes = Uint8Array.from(persistedBuffer); - const aligned = await alignAudioWithText( - whisperBytes.buffer, - segment.text, - undefined, - { engine: 'whisper.cpp' }, - ); + const aligned = (await getCompute().alignWords({ + audioBuffer: whisperBytes.buffer, + text: segment.text, + })).alignments; alignment = aligned[0] ? { ...aligned[0], sentenceIndex: segment.original.segmentIndex } : null; } catch (alignError) { console.warn('Whisper alignment unavailable for segment; continuing without word highlights.', { diff --git a/src/app/api/whisper/route.ts b/src/app/api/whisper/route.ts index 4c73ff3..5bfe152 100644 --- a/src/app/api/whisper/route.ts +++ b/src/app/api/whisper/route.ts @@ -1,11 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import type { TTSSentenceAlignment } from '@/types/tts'; import { auth } from '@/lib/server/auth/auth'; -import { - alignAudioWithText, - makeWhisperCacheKey, - type WhisperRequestBody, -} from '@/lib/server/whisper/alignment'; +import { makeWhisperCacheKey, type WhisperRequestBody } from '@/lib/server/whisper/alignment'; +import { getCompute } from '@/lib/server/compute'; export const runtime = 'nodejs'; @@ -29,12 +26,12 @@ export async function POST(req: NextRequest) { const cacheKey = makeWhisperCacheKey(body); const audioBuffer = new Uint8Array(audio).buffer; - const alignments: TTSSentenceAlignment[] = await alignAudioWithText( + const alignments: TTSSentenceAlignment[] = (await getCompute().alignWords({ audioBuffer, text, cacheKey, - { engine: 'whisper.cpp', lang } - ); + lang, + })).alignments; return NextResponse.json({ alignments }, { status: 200 }); } catch (error) { diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 8986daa..41aca85 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -303,14 +303,6 @@ export function AdminFeaturesPanel() { right={renderSource('enableUserSignups')} variant="flat" /> - updateDraft('enableWordHighlight', checked)} - right={renderSource('enableWordHighlight')} - variant="flat" - /> = [ + { kind: 'page-header', label: 'Page headers' }, + { kind: 'page-footer', label: 'Page footers' }, + { kind: 'footnote', label: 'Footnotes' }, + { kind: 'caption', label: 'Captions' }, +]; const viewTypeTextMapping = [ { id: 'single', name: 'Single Page' }, @@ -71,13 +80,25 @@ function RangeSetting({ ); } -export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { +export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { isOpen: boolean, setIsOpen: (isOpen: boolean) => void, epub?: boolean, - html?: boolean + html?: boolean, + pdf?: { + computeAvailable: boolean; + parseStatus: PdfParseStatus | null; + parsedOverlayEnabled: boolean; + skipBlockKinds: ParsedPdfBlockKind[]; + chaptersFromSections: boolean; + onToggleOverlay: (enabled: boolean) => void; + onToggleSkipKind: (kind: ParsedPdfBlockKind, enabled: boolean) => void; + onToggleChaptersFromSections: (enabled: boolean) => void; + onForceReparse: () => void; + } }) { - const canWordHighlight = useFeatureFlag('enableWordHighlight'); + const computeAvailable = useFeatureFlag('computeAvailable'); + const canWordHighlight = computeAvailable; const { viewType, skipBlank, @@ -315,7 +336,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { /> updateConfigKey('pdfWordHighlightEnabled', checked)} @@ -346,7 +367,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { /> updateConfigKey('epubWordHighlightEnabled', checked)} @@ -370,7 +391,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { /> updateConfigKey('htmlWordHighlightEnabled', checked)} @@ -378,6 +399,59 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { /> )} + + {!epub && !html && pdf && ( +
+
+ Parse status: {pdf.parseStatus ?? 'pending'} + +
+ + +
+

Skip while reading aloud

+ {PDF_SKIP_KIND_OPTIONS.map((option) => { + const checked = pdf.skipBlockKinds.includes(option.kind); + return ( + pdf.onToggleSkipKind(option.kind, enabled)} + variant="flat" + /> + ); + })} +
+
+ )} ); diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index 01c980c..1cc8e1a 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -10,6 +10,7 @@ import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { usePDFResize } from '@/hooks/pdf/usePDFResize'; import type { PdfDocumentState } from '@/app/(app)/pdf/[id]/usePdfDocument'; +import type { ParsedPdfBlock, ParsedPdfPage } from '@/types/parsed-pdf'; interface PDFViewerProps { zoomLevel: number; @@ -25,6 +26,8 @@ interface PDFViewerProps { | 'currDocPages' | 'currDocText' | 'currDocPage' + | 'parsedDocument' + | 'parsedOverlayEnabled' >; } @@ -67,6 +70,8 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { currDocPages, currDocText, currDocPage, + parsedDocument, + parsedOverlayEnabled, } = pdfState; // IMPORTANT: @@ -299,6 +304,107 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { return scaleRef.current; }, [calculateScale]); + const parsedPageByNumber = useMemo(() => { + const map = new Map(); + for (const page of parsedDocument?.pages ?? []) { + map.set(page.pageNumber, page); + } + return map; + }, [parsedDocument]); + + const parsedOverlayByPage = useMemo(() => { + const map = new Map>(); + + const seen = new Set(); + for (const page of parsedDocument?.pages ?? []) { + for (const block of page.blocks) { + for (let fragmentIndex = 0; fragmentIndex < block.fragments.length; fragmentIndex += 1) { + const fragment = block.fragments[fragmentIndex]; + if (!fragment) continue; + const key = `${block.id}:${fragment.page}:${fragment.readingOrder}`; + if (seen.has(key)) continue; + seen.add(key); + + const list = map.get(fragment.page) ?? []; + list.push({ + block, + fragment, + isContinuation: fragmentIndex > 0, + }); + map.set(fragment.page, list); + } + } + } + + for (const list of map.values()) { + list.sort((a, b) => { + if (a.fragment.readingOrder !== b.fragment.readingOrder) { + return a.fragment.readingOrder - b.fragment.readingOrder; + } + return a.block.id.localeCompare(b.block.id); + }); + } + + return map; + }, [parsedDocument]); + + const colorForKind = (kind: ParsedPdfBlock['kind']): string => { + switch (kind) { + case 'section-header': return 'rgba(34,197,94,0.20)'; + case 'title': return 'rgba(16,185,129,0.20)'; + case 'caption': return 'rgba(245,158,11,0.20)'; + case 'table': return 'rgba(59,130,246,0.20)'; + case 'picture': return 'rgba(139,92,246,0.20)'; + case 'page-header': + case 'page-footer': + case 'footnote': return 'rgba(239,68,68,0.20)'; + default: return 'rgba(14,165,233,0.18)'; + } + }; + + const renderParsedOverlay = (pageNumber: number) => { + if (!parsedOverlayEnabled) return null; + const parsedPage = parsedPageByNumber.get(pageNumber); + if (!parsedPage) return null; + const overlayEntries = parsedOverlayByPage.get(pageNumber) ?? []; + return ( +
+ {overlayEntries.map(({ block, fragment, isContinuation }) => { + const [x0, y0, x1, y1] = fragment.bbox; + const width = parsedPage.width || 1; + const height = parsedPage.height || 1; + const leftPct = (x0 / width) * 100; + const boxWidthPct = Math.max(0, ((x1 - x0) / width) * 100); + // Parsed model bboxes are top-left based; use y0 directly. + const topPct = (y0 / height) * 100; + const boxHeightPct = Math.max(0, ((y1 - y0) / height) * 100); + + return ( +
+ + {isContinuation ? `${block.kind} (cont)` : block.kind} + +
+ ); + })} +
+ ); + }; + return (
{currDocPages && [...Array(currDocPages)].map((_, i) => ( - { - lastRenderedLayoutKeyRef.current = layoutKey; - setIsPageRendering(false); - }} - onLoadSuccess={(page) => { - setPageWidth(page.originalWidth); - setPageHeight(page.originalHeight); - }} - /> +
+ { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + }} + onLoadSuccess={(page) => { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + {renderParsedOverlay(i + 1)} +
))}
) : ( // Single/Dual page mode
{currDocPages && leftPage > 0 && ( - { - lastRenderedLayoutKeyRef.current = layoutKey; - setIsPageRendering(false); - }} - onLoadSuccess={(page) => { - setPageWidth(page.originalWidth); - setPageHeight(page.originalHeight); - }} - /> +
+ { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + }} + onLoadSuccess={(page) => { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + {renderParsedOverlay(leftPage)} +
)} {currDocPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && ( - { - lastRenderedLayoutKeyRef.current = layoutKey; - setIsPageRendering(false); - }} - onLoadSuccess={(page) => { - setPageWidth(page.originalWidth); - setPageHeight(page.originalHeight); - }} - /> +
+ { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + }} + onLoadSuccess={(page) => { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + {renderParsedOverlay(rightPage)} +
)}
)} diff --git a/src/contexts/RuntimeConfigContext.tsx b/src/contexts/RuntimeConfigContext.tsx index 16a665c..e1e3991 100644 --- a/src/contexts/RuntimeConfigContext.tsx +++ b/src/contexts/RuntimeConfigContext.tsx @@ -18,11 +18,11 @@ export interface RuntimeConfig { enableUserSignups: boolean; restrictUserApiKeys: boolean; enableTtsProvidersTab: boolean; - enableWordHighlight: boolean; enableAudiobookExport: boolean; enableDocxConversion: boolean; enableDestructiveDeleteActions: boolean; showAllProviderModels: boolean; + computeAvailable: boolean; } const RUNTIME_DEFAULTS: RuntimeConfig = { @@ -32,11 +32,11 @@ const RUNTIME_DEFAULTS: RuntimeConfig = { enableUserSignups: true, restrictUserApiKeys: true, enableTtsProvidersTab: true, - enableWordHighlight: true, enableAudiobookExport: true, enableDocxConversion: true, enableDestructiveDeleteActions: true, showAllProviderModels: true, + computeAvailable: true, }; declare global { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index fa4b428..8bbc362 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -159,6 +159,7 @@ interface TTSContextType extends TTSPlaybackState { interface SetTextOptions { shouldPause?: boolean; location?: TTSLocation; + sourceUnits?: CanonicalTtsSourceUnit[]; previousLocation?: TTSLocation; nextLocation?: TTSLocation; nextText?: string; @@ -218,8 +219,8 @@ const wordHighlightFeatureEnabled = (() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__; if (!injected || typeof injected !== 'object') return true; - return typeof injected.enableWordHighlight === 'boolean' - ? injected.enableWordHighlight + return typeof injected.computeAvailable === 'boolean' + ? injected.computeAvailable : true; })(); @@ -292,6 +293,11 @@ const buildLocatorRequestKey = (locator: TTSSegmentLocator): string => { if (typeof locator.location === 'string' && locator.location) { return normalizeLocationKey(locator.location); } + if (locator.readerType === 'pdf') { + const page = Number(locator.page || 1); + const block = typeof locator.blockId === 'string' && locator.blockId ? locator.blockId : ''; + return normalizeLocationKey(`pdf:${page}:${block}`); + } return normalizeLocationKey(Number(locator.page || 1)); }; @@ -1124,6 +1130,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ? normalizedOptions.location : currDocPage; const resolvedLocationKey = normalizeLocationKey(resolvedLocation); + const currentUnits = normalizedOptions.sourceUnits && normalizedOptions.sourceUnits.length > 0 + ? normalizedOptions.sourceUnits + : null; // Keep currDocPage aligned with whatever the caller declared as the viewport's // location. This is the canonical entry point for "the rendered page now shows @@ -1142,6 +1151,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement text, locator: locatorForLocation(resolvedLocation, activeReaderType), }; + const effectiveCurrentUnits = currentUnits && currentUnits.length > 0 ? currentUnits : [currentSource]; + const currentSourceKeySet = new Set(effectiveCurrentUnits.map((unit) => unit.sourceKey)); const contextSourceUnits: CanonicalTtsSourceUnit[] = []; if (smartSentenceSplitting && normalizedOptions.previousText?.trim()) { @@ -1156,7 +1167,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement : null, }); } - contextSourceUnits.push(currentSource); + contextSourceUnits.push(...effectiveCurrentUnits); const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits]; plannedSegmentsByLocationRef.current.clear(); @@ -1191,14 +1202,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, }); const currentSegments = smartSentenceSplitting - ? plan.segments.filter((segment) => segment.ownerSourceKey === currentSourceKey) - : planCanonicalTtsSegments([currentSource], { - readerType: activeReaderType, - maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - }).segments; + ? plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey)) + : effectiveCurrentUnits.flatMap((unit) => + planCanonicalTtsSegments([unit], { + readerType: activeReaderType, + maxBlockLength: ttsSegmentMaxBlockLength, + keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, + }).segments); const newSentences = currentSegments.map((segment) => segment.text); for (const item of pendingPrefetches) { @@ -1208,13 +1222,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, }).segments; if (planned.length > 0) { plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned); } } - currentSourceUnitRef.current = currentSource; + currentSourceUnitRef.current = effectiveCurrentUnits[0] ?? null; currentSourceContextUnitsRef.current = contextSourceUnits; if (handleBlankSection(newSentences.join(' '))) return; @@ -1288,7 +1303,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); - if (smartSentenceSplitting && !isEPUB && normalizedOptions.nextLocation !== undefined) { + if ( + smartSentenceSplitting + && !isEPUB + && normalizedOptions.nextLocation !== undefined + && effectiveCurrentUnits.length === 1 + ) { const spanningIndex = currentSegments.findIndex((segment) => segment.spansSourceBoundary && segment.startAnchor.sourceKey === currentSourceKey diff --git a/src/db/schema.ts b/src/db/schema.ts index 6b3bab9..b295a06 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -11,6 +11,7 @@ export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters; export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars; export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences; +export const documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings; export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress; export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews; export const ttsSegmentEntries = usePostgres ? postgresSchema.ttsSegmentEntries : sqliteSchema.ttsSegmentEntries; diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 9778aae..d87ef26 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -12,6 +12,8 @@ export const documents = pgTable('documents', { size: bigint('size', { mode: 'number' }).notNull(), lastModified: bigint('last_modified', { mode: 'number' }).notNull(), filePath: text('file_path').notNull(), + parseStatus: text('parse_status'), + parsedJsonKey: text('parsed_json_key'), createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), @@ -72,6 +74,18 @@ export const userPreferences = pgTable('user_preferences', { 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' }), + dataJson: jsonb('data_json').notNull().default({}), + clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).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.documentId, table.userId] }), + index('idx_document_settings_user_id').on(table.userId), +]); + export const userDocumentProgress = pgTable('user_document_progress', { userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), documentId: text('document_id').notNull(), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 920a42d..1697a18 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -12,6 +12,8 @@ export const documents = sqliteTable('documents', { size: integer('size').notNull(), lastModified: integer('last_modified').notNull(), filePath: text('file_path').notNull(), + parseStatus: text('parse_status'), + parsedJsonKey: text('parsed_json_key'), createdAt: integer('created_at').default(SQLITE_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), @@ -72,6 +74,18 @@ export const userPreferences = sqliteTable('user_preferences', { 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' }), + dataJson: text('data_json').notNull().default('{}'), + clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0), + createdAt: integer('created_at').default(SQLITE_NOW_MS), + updatedAt: integer('updated_at').default(SQLITE_NOW_MS), +}, (table) => [ + primaryKey({ columns: [table.documentId, table.userId] }), + index('idx_document_settings_user_id').on(table.userId), +]); + export const userDocumentProgress = sqliteTable('user_document_progress', { userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), documentId: text('document_id').notNull(), diff --git a/src/lib/client/api/documents.ts b/src/lib/client/api/documents.ts index 5cd5697..829a392 100644 --- a/src/lib/client/api/documents.ts +++ b/src/lib/client/api/documents.ts @@ -1,5 +1,7 @@ import { sha256HexFromArrayBuffer } from '@/lib/client/sha256'; import type { BaseDocument, DocumentType } from '@/types/documents'; +import type { ParsedPdfDocument } from '@/types/parsed-pdf'; +import type { DocumentSettings } from '@/types/document-settings'; export type UploadSource = { id: string; @@ -76,6 +78,95 @@ export async function getDocumentMetadata(id: string, options?: { signal?: Abort return docs[0] ?? null; } +export async function getParsedPdfDocument( + id: string, + options?: { signal?: AbortSignal; retryFailed?: boolean }, +): Promise<{ status: 'ready'; parsed: ParsedPdfDocument } | { status: 'pending' | 'running' | 'failed' | 'unsupported' }> { + const query = options?.retryFailed ? '?retry=1' : ''; + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed${query}`, { + signal: options?.signal, + cache: 'no-store', + }); + + if (res.status === 202) { + const data = (await res.json().catch(() => null)) as { parseStatus?: string } | null; + const parseStatus = data?.parseStatus; + if (parseStatus === 'pending' || parseStatus === 'running' || parseStatus === 'failed' || parseStatus === 'unsupported') { + return { status: parseStatus }; + } + return { status: 'pending' }; + } + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to load parsed PDF'); + } + + const parsed = (await res.json()) as ParsedPdfDocument; + return { status: 'ready', parsed }; +} + +export async function forceReparsePdfDocument( + id: string, + options?: { signal?: AbortSignal }, +): Promise<'pending' | 'running'> { + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed`, { + method: 'POST', + signal: options?.signal, + cache: 'no-store', + }); + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to force PDF reparse'); + } + + const data = (await res.json().catch(() => null)) as { parseStatus?: string } | null; + return data?.parseStatus === 'running' ? 'running' : 'pending'; +} + +type DocumentSettingsResponse = { + settings: DocumentSettings; + clientUpdatedAtMs: number; + hasStoredSettings?: boolean; +}; + +export async function getDocumentSettings( + id: string, + options?: { signal?: AbortSignal }, +): Promise { + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/settings`, { + signal: options?.signal, + cache: 'no-store', + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to load document settings'); + } + return (await res.json()) as DocumentSettingsResponse; +} + +export async function putDocumentSettings( + id: string, + settings: DocumentSettings, + options?: { signal?: AbortSignal; clientUpdatedAtMs?: number }, +): Promise { + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings, + clientUpdatedAtMs: options?.clientUpdatedAtMs ?? Date.now(), + }), + signal: options?.signal, + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to update document settings'); + } + return (await res.json()) as DocumentSettingsResponse & { applied: boolean }; +} + export async function uploadDocumentSources(sources: UploadSource[], options?: UploadOptions): Promise { if (sources.length === 0) return []; diff --git a/src/lib/client/audiobooks/adapters/pdf.ts b/src/lib/client/audiobooks/adapters/pdf.ts index 85300f5..4b52581 100644 --- a/src/lib/client/audiobooks/adapters/pdf.ts +++ b/src/lib/client/audiobooks/adapters/pdf.ts @@ -1,11 +1,15 @@ import type { PDFDocumentProxy } from 'pdfjs-dist'; -import { extractTextFromPDF } from '@/lib/client/pdf'; import type { AudiobookSourceAdapter, PreparedAudiobookChapter } from '@/lib/client/audiobooks/pipeline'; import { normalizeTextForTts } from '@/lib/shared/nlp'; +import type { ParsedPdfDocument, ParsedPdfBlock } from '@/types/parsed-pdf'; +import type { DocumentSettings } from '@/types/document-settings'; +import { DEFAULT_DOCUMENT_SETTINGS } from '@/types/document-settings'; interface PdfAudiobookAdapterOptions { pdfDocument?: PDFDocumentProxy; + parsed?: ParsedPdfDocument; + settings?: DocumentSettings; margins: { header: number; footer: number; @@ -16,16 +20,99 @@ interface PdfAudiobookAdapterOptions { maxBlockLength?: number; } +function chapterTextFromBlocks( + blocks: ParsedPdfBlock[], + smartSentenceSplitting: boolean, + maxBlockLength?: number, +): string { + const text = blocks + .map((block) => block.text.trim()) + .filter(Boolean) + .join('\n\n'); + if (!text) return ''; + return smartSentenceSplitting ? normalizeTextForTts(text, { maxBlockLength }) : text; +} + +function prepareParsedChapters({ + parsed, + settings, + smartSentenceSplitting, + maxBlockLength, +}: { + parsed: ParsedPdfDocument; + settings: DocumentSettings; + smartSentenceSplitting: boolean; + maxBlockLength?: number; +}): PreparedAudiobookChapter[] { + const skip = new Set(settings.pdf?.skipBlockKinds ?? DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? []); + const allBlocks = parsed.pages + .flatMap((page) => page.blocks) + .filter((block) => !skip.has(block.kind)); + if (!allBlocks.length) return []; + + const chaptersFromSections = settings.pdf?.chaptersFromSections ?? true; + if (!chaptersFromSections) { + const text = chapterTextFromBlocks(allBlocks, smartSentenceSplitting, maxBlockLength); + return text ? [{ index: 0, title: 'Document', text }] : []; + } + + const chapters: PreparedAudiobookChapter[] = []; + let currentTitle = 'Introduction'; + let currentBlocks: ParsedPdfBlock[] = []; + + const flush = () => { + if (!currentBlocks.length) return; + const text = chapterTextFromBlocks(currentBlocks, smartSentenceSplitting, maxBlockLength); + if (text) { + chapters.push({ + index: chapters.length, + title: currentTitle, + text, + }); + } + currentBlocks = []; + }; + + for (const block of allBlocks) { + if (block.kind === 'section-header') { + flush(); + currentTitle = block.text.trim() || `Chapter ${chapters.length + 1}`; + currentBlocks.push(block); + continue; + } + currentBlocks.push(block); + } + flush(); + + return chapters; +} + async function extractPreparedPdfChapters({ pdfDocument, + parsed, + settings = DEFAULT_DOCUMENT_SETTINGS, margins, smartSentenceSplitting, maxBlockLength, }: PdfAudiobookAdapterOptions): Promise { + if (parsed) { + const parsedChapters = prepareParsedChapters({ + parsed, + settings, + smartSentenceSplitting, + maxBlockLength, + }); + if (parsedChapters.length > 0) { + return parsedChapters; + } + } + if (!pdfDocument) { throw new Error('No PDF document loaded'); } + const { extractTextFromPDF } = await import('@/lib/client/pdf'); + const chapters: PreparedAudiobookChapter[] = []; for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) { const rawText = await extractTextFromPDF(pdfDocument, pageNum, margins); diff --git a/src/lib/client/pdf-block-text.ts b/src/lib/client/pdf-block-text.ts new file mode 100644 index 0000000..ac793ac --- /dev/null +++ b/src/lib/client/pdf-block-text.ts @@ -0,0 +1,20 @@ +import type { ParsedPdfBlockKind, ParsedPdfPage } from '@/types/parsed-pdf'; + +export function buildPageTextFromBlocks( + page: ParsedPdfPage, + skipKinds: ParsedPdfBlockKind[] = [], +): string { + const skip = new Set(skipKinds); + return page.blocks + .filter((block) => !skip.has(block.kind)) + .sort((a, b) => { + const aOrder = a.fragments[0]?.readingOrder ?? 0; + const bOrder = b.fragments[0]?.readingOrder ?? 0; + return aOrder - bOrder; + }) + .map((block) => block.text.trim()) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/src/lib/client/pdf.ts b/src/lib/client/pdf.ts index 6c09d24..8ecc635 100644 --- a/src/lib/client/pdf.ts +++ b/src/lib/client/pdf.ts @@ -3,6 +3,8 @@ import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist'; import "core-js/proposals/promise-with-resolvers"; import type { TTSSentenceAlignment } from '@/types/tts'; +import type { ParsedPdfPage, ParsedPdfBlockKind } from '@/types/parsed-pdf'; +import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text'; import { CmpStr } from 'cmpstr'; const cmp = CmpStr.create().setMetric('dice').setFlags('itw'); @@ -80,18 +82,16 @@ function runHighlightTokenMatch( function shouldUseLegacyBuild() { try { if (typeof window === 'undefined') return false; - + const ua = window.navigator.userAgent; const isSafari = /^((?!chrome|android).)*safari/i.test(ua); - - console.log(isSafari ? 'Running on Safari' : 'Not running on Safari'); + if (!isSafari) return false; - + // Extract Safari version - matches "Version/18" format const match = ua.match(/Version\/(\d+)/i); - console.log('Safari version:', match); if (!match || !match[1]) return true; // If we can't determine version, use legacy to be safe - + const version = parseInt(match[1]); return version < 18; // Use legacy build for Safari versions equal or below 18 } catch (e) { @@ -106,10 +106,9 @@ function initPDFWorker() { if (typeof window !== 'undefined') { const useLegacy = shouldUseLegacyBuild(); // Use local worker file instead of unpkg - const workerSrc = useLegacy + const workerSrc = useLegacy ? new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs', import.meta.url).href : new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href; - console.log('Setting PDF worker to:', workerSrc); pdfjs.GlobalWorkerOptions.workerSrc = workerSrc; pdfjs.GlobalWorkerOptions.workerPort = null; } @@ -192,35 +191,38 @@ const normalizeWordForMatch = (text: string): string => export async function extractTextFromPDF( pdf: PDFDocumentProxy, pageNumber: number, - margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 } + margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }, + parsed?: ParsedPdfPage, + skipKinds?: ParsedPdfBlockKind[], ): Promise { try { - // Log pdf worker version - //console.log('PDF worker version:', pdfjs.GlobalWorkerOptions.workerSrc); + if (parsed) { + return buildPageTextFromBlocks(parsed, skipKinds ?? []); + } const page = await pdf.getPage(pageNumber); const textContent = await page.getTextContent(); - + const viewport = page.getViewport({ scale: 1.0 }); const pageHeight = viewport.height; const pageWidth = viewport.width; const textItems = textContent.items.filter((item): item is TextItem => { if (!('str' in item && 'transform' in item)) return false; - + const [scaleX, skewX, skewY, scaleY, x, y] = item.transform; - + // Basic text filtering if (Math.abs(scaleX) < 1 || Math.abs(scaleX) > 20) return false; if (Math.abs(scaleY) < 1 || Math.abs(scaleY) > 20) return false; if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false; - + // Calculate margins in PDF coordinate space (y=0 is at bottom) const headerY = pageHeight * (1 - margins.header); // Convert from top margin to bottom-based Y const footerY = pageHeight * margins.footer; // Footer Y stays as is since it's already bottom-based const leftX = pageWidth * margins.left; const rightX = pageWidth * (1 - margins.right); - + // Check margins - remember y=0 is at bottom of page in PDF coordinates if (y > headerY || y < footerY) { // Y greater than headerY means it's in header area, less than footerY means footer area return false; @@ -230,15 +232,13 @@ export async function extractTextFromPDF( if (x < leftX || x > rightX) { return false; } - + // Sanity check for coordinates if (x < 0 || x > pageWidth) return false; - + return item.str.trim().length > 0; }); - //console.log('Filtered text items:', textItems); - const tolerance = 2; const lines: TextItem[][] = []; let currentLine: TextItem[] = []; diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 8e55824..598d46f 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -82,7 +82,6 @@ export const RUNTIME_CONFIG_SCHEMA = { // Historically the env semantics were "true unless explicitly 'false'", // i.e. the feature defaults to ON. enableTtsProvidersTab: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB'), - enableWordHighlight: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT'), enableAudiobookExport: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT'), enableDocxConversion: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DOCX_CONVERSION'), enableDestructiveDeleteActions: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'), diff --git a/src/lib/server/auth/auth.ts b/src/lib/server/auth/auth.ts index 7839e42..9cf8d26 100644 --- a/src/lib/server/auth/auth.ts +++ b/src/lib/server/auth/auth.ts @@ -139,7 +139,6 @@ const createAuth = () => betterAuth({ }, }, plugins: [ - nextCookies(), // Enable Next.js cookie handling ...(isAnonymousAuthSessionsEnabled() ? [ anonymous({ @@ -219,6 +218,9 @@ const createAuth = () => betterAuth({ }), ] : []), + // Better Auth requires cookie integration plugins last so post-hooks can + // still append Set-Cookie headers that are forwarded to Next.js. + nextCookies(), ], }); diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts new file mode 100644 index 0000000..f0700bb --- /dev/null +++ b/src/lib/server/compute/index.ts @@ -0,0 +1,37 @@ +import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types'; +import { LocalComputeBackend } from '@/lib/server/compute/local'; +import { NoneComputeBackend } from '@/lib/server/compute/none'; + +let backend: ComputeBackend | null = null; + +function readMode(): ComputeMode { + const raw = (process.env.OPENREADER_COMPUTE_MODE || 'local').trim().toLowerCase(); + if (raw === 'local' || raw === 'none' || raw === 'worker') return raw; + return 'local'; +} + +function createBackend(): ComputeBackend { + const mode = readMode(); + if (mode === 'none') return new NoneComputeBackend(); + if (mode === 'worker') { + throw new Error( + 'OPENREADER_COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', + ); + } + return new LocalComputeBackend(); +} + +export function getCompute(): ComputeBackend { + if (!backend) backend = createBackend(); + return backend; +} + +export function isComputeAvailable(): boolean { + const mode = readMode(); + if (mode === 'worker') { + throw new Error( + 'OPENREADER_COMPUTE_MODE=worker is not implemented yet in v1. Switch to local/none or implement WorkerComputeBackend (v2).', + ); + } + return mode !== 'none'; +} diff --git a/src/lib/server/compute/local.ts b/src/lib/server/compute/local.ts new file mode 100644 index 0000000..2f95a98 --- /dev/null +++ b/src/lib/server/compute/local.ts @@ -0,0 +1,21 @@ +import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; +import { alignAudioWithText } from '@/lib/server/whisper/alignment'; +import { parsePdf } from '@/lib/server/pdf-layout/parsePdf'; + +export class LocalComputeBackend implements ComputeBackend { + readonly mode = 'local' as const; + + async alignWords(input: WhisperAlignInput): Promise { + const alignments = await alignAudioWithText( + input.audioBuffer, + input.text, + input.cacheKey, + { engine: 'whisper.cpp', lang: input.lang }, + ); + return { alignments }; + } + + async parsePdfLayout(input: PdfLayoutInput) { + return parsePdf({ documentId: input.documentId, pdfBytes: input.pdfBytes }); + } +} diff --git a/src/lib/server/compute/none.ts b/src/lib/server/compute/none.ts new file mode 100644 index 0000000..a2f6695 --- /dev/null +++ b/src/lib/server/compute/none.ts @@ -0,0 +1,17 @@ +import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; +import { UnsupportedComputeError } from '@/lib/server/compute/types'; +import type { ParsedPdfDocument } from '@/types/parsed-pdf'; + +export class NoneComputeBackend implements ComputeBackend { + readonly mode = 'none' as const; + + async alignWords(input: WhisperAlignInput): Promise { + void input; + throw new UnsupportedComputeError('Word alignment is unavailable: OPENREADER_COMPUTE_MODE=none'); + } + + async parsePdfLayout(input: PdfLayoutInput): Promise { + void input; + throw new UnsupportedComputeError('PDF layout parsing is unavailable: OPENREADER_COMPUTE_MODE=none'); + } +} diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts new file mode 100644 index 0000000..3a411eb --- /dev/null +++ b/src/lib/server/compute/types.ts @@ -0,0 +1,33 @@ +import type { TTSAudioBuffer, TTSSentenceAlignment } from '@/types/tts'; +import type { ParsedPdfDocument } from '@/types/parsed-pdf'; + +export type ComputeMode = 'local' | 'worker' | 'none'; + +export interface WhisperAlignInput { + audioBuffer: TTSAudioBuffer; + text: string; + cacheKey?: string; + lang?: string; +} + +export interface WhisperAlignResult { + alignments: TTSSentenceAlignment[]; +} + +export interface PdfLayoutInput { + documentId: string; + pdfBytes: ArrayBuffer; +} + +export interface ComputeBackend { + mode: ComputeMode; + alignWords(input: WhisperAlignInput): Promise; + parsePdfLayout(input: PdfLayoutInput): Promise; +} + +export class UnsupportedComputeError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnsupportedComputeError'; + } +} diff --git a/src/lib/server/documents/blobstore.ts b/src/lib/server/documents/blobstore.ts index d99f444..d94d9f2 100644 --- a/src/lib/server/documents/blobstore.ts +++ b/src/lib/server/documents/blobstore.ts @@ -80,11 +80,55 @@ export function documentKey(id: string, namespace: string | null): string { return `${cfg.prefix}/documents_v1/${nsSegment}${id}`; } +export function documentParsedKey(id: string, namespace: string | null): string { + if (!isValidDocumentId(id)) { + throw new Error(`Invalid document id: ${id}`); + } + const cfg = getS3Config(); + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${cfg.prefix}/documents_v1/parsed_v1/${nsSegment}${id}.json`; +} + +function legacyDocumentParsedKey(id: string, namespace: string | null): string { + if (!isValidDocumentId(id)) { + throw new Error(`Invalid document id: ${id}`); + } + const cfg = getS3Config(); + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${cfg.prefix}/documents_v1/${nsSegment}${id}/parsed.v1.json`; +} + +async function cleanupLegacyParsedPathCollision(id: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3ProxyClient(); + const key = documentKey(id, namespace); + const legacyPrefix = `${key}/`; + const legacyParsedKey = legacyDocumentParsedKey(id, namespace); + + const legacyObjects = await client.send( + new ListObjectsV2Command({ + Bucket: cfg.bucket, + Prefix: legacyPrefix, + MaxKeys: 1, + }), + ); + const hasLegacyChildren = (legacyObjects.Contents?.length ?? 0) > 0; + if (!hasLegacyChildren) return; + + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: legacyParsedKey })).catch(() => undefined); + await deleteDocumentPrefix(legacyPrefix).catch(() => undefined); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })).catch(() => undefined); +} + export async function presignPut( id: string, contentType: string, namespace: string | null, ): Promise<{ url: string; headers: Record }> { + await cleanupLegacyParsedPathCollision(id, namespace); + const cfg = getS3Config(); const client = getS3Client(); const key = documentKey(id, namespace); @@ -94,7 +138,6 @@ export async function presignPut( Bucket: cfg.bucket, Key: key, ContentType: normalizedType, - IfNoneMatch: '*', ServerSideEncryption: 'AES256', }); const url = await getSignedUrl(client, command, { expiresIn: 60 * 5 }); @@ -103,7 +146,6 @@ export async function presignPut( url, headers: { 'Content-Type': normalizedType, - 'If-None-Match': '*', 'x-amz-server-side-encryption': 'AES256', }, }; @@ -169,6 +211,35 @@ export async function getDocumentBlobStream(id: string, namespace: string | null return res.Body as DocumentBlobBody; } +export async function getParsedDocumentBlob(id: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3ProxyClient(); + const key = documentParsedKey(id, namespace); + const res = await client.send( + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: key, + }), + ); + return bodyToBuffer(res.Body); +} + +export async function putParsedDocumentBlob(id: string, body: Buffer, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3ProxyClient(); + const key = documentParsedKey(id, namespace); + await client.send( + new PutObjectCommand({ + Bucket: cfg.bucket, + Key: key, + Body: body, + ContentType: 'application/json', + ServerSideEncryption: 'AES256', + }), + ); + return key; +} + export async function presignGet( id: string, namespace: string | null, @@ -193,6 +264,8 @@ export async function putDocumentBlob( contentType: string, namespace: string | null, ): Promise { + await cleanupLegacyParsedPathCollision(id, namespace); + const cfg = getS3Config(); const client = getS3ProxyClient(); const key = documentKey(id, namespace); @@ -202,7 +275,6 @@ export async function putDocumentBlob( Key: key, Body: body, ContentType: contentType, - IfNoneMatch: '*', ServerSideEncryption: 'AES256', }), ); @@ -212,7 +284,13 @@ export async function deleteDocumentBlob(id: string, namespace: string | null): const cfg = getS3Config(); const client = getS3ProxyClient(); const key = documentKey(id, namespace); + const parsedKey = documentParsedKey(id, namespace); + const legacyParsedKey = legacyDocumentParsedKey(id, namespace); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: parsedKey })).catch(() => undefined); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: legacyParsedKey })).catch(() => undefined); + await deleteDocumentPrefix(`${key}/`).catch(() => undefined); } export function isMissingBlobError(error: unknown): boolean { diff --git a/src/lib/server/jobs/parsePdfJob.ts b/src/lib/server/jobs/parsePdfJob.ts new file mode 100644 index 0000000..eab9f02 --- /dev/null +++ b/src/lib/server/jobs/parsePdfJob.ts @@ -0,0 +1,92 @@ +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { UnsupportedComputeError } from '@/lib/server/compute/types'; +import { getDocumentBlob, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; +import { getCompute } from '@/lib/server/compute'; +import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; + +interface ParsePdfJobInput { + documentId: string; + userId: string; + namespace: string | null; +} + +const running = new Set(); + +function keyFor(input: ParsePdfJobInput): string { + return `${input.userId}:${input.documentId}:${input.namespace || ''}`; +} + +export async function parsePdfJob(input: ParsePdfJobInput): Promise { + const key = keyFor(input); + if (running.has(key)) return; + running.add(key); + + try { + await db + .update(documents) + .set({ parseStatus: 'running' }) + .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); + + const pdfBytes = await getDocumentBlob(input.documentId, input.namespace); + const parsed = await getCompute().parsePdfLayout({ + documentId: input.documentId, + pdfBytes: new Uint8Array(pdfBytes).buffer, + }); + + const parsedJson = Buffer.from(JSON.stringify(parsed)); + const parsedJsonKey = await putParsedDocumentBlob(input.documentId, parsedJson, input.namespace); + + const cleared = await clearTtsSegmentCache({ + userId: input.userId, + documentId: input.documentId, + readerType: 'pdf', + }); + if (cleared.warning) { + console.warn('[parsePdfJob] cache invalidation warning', { + documentId: input.documentId, + warning: cleared.warning, + }); + } + + await db + .update(documents) + .set({ parseStatus: 'ready', parsedJsonKey }) + .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + const cause = error instanceof Error ? error.cause : undefined; + const parseStatus = error instanceof UnsupportedComputeError ? 'unsupported' : 'failed'; + try { + await db + .update(documents) + .set({ parseStatus }) + .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); + } catch (statusError) { + console.error('[parsePdfJob] failed to write parse status', { + documentId: input.documentId, + parseStatus, + error: statusError instanceof Error ? statusError.message : String(statusError), + }); + } + console.error('[parsePdfJob] failed', { + documentId: input.documentId, + parseStatus, + error: message, + ...(stack ? { stack } : {}), + ...(cause ? { cause: String(cause) } : {}), + }); + } finally { + running.delete(key); + } +} + +export function enqueueParsePdfJob(input: ParsePdfJobInput): void { + Promise.resolve() + .then(() => parsePdfJob(input)) + .catch((error) => { + console.error('[parsePdfJob] uncaught error', error); + }); +} diff --git a/src/lib/server/pdf-layout/ensureModel.ts b/src/lib/server/pdf-layout/ensureModel.ts new file mode 100644 index 0000000..70d5c73 --- /dev/null +++ b/src/lib/server/pdf-layout/ensureModel.ts @@ -0,0 +1,99 @@ +import path from 'path'; +import { createHash } from 'crypto'; +import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs/promises'; +import { DOCSTORE_DIR } from '@/lib/server/storage/library-mount'; +import manifest from '@/lib/server/pdf-layout/model/manifest.json'; + +const DEFAULT_MODEL_URL = 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/model.onnx'; +const DEFAULT_CONFIG_URL = 'https://huggingface.co/docling-project/docling-layout-heron-onnx/resolve/main/config.json'; +const MODEL_DIR = path.join(DOCSTORE_DIR, 'model'); +const MODEL_PATH = path.join(MODEL_DIR, 'docling-layout-heron.onnx'); +const CONFIG_PATH = path.join(MODEL_DIR, 'docling-layout-heron.config.json'); +const LICENSE_PATH = path.join(MODEL_DIR, 'docling-layout-heron.LICENSE.txt'); +const STATIC_LICENSE_PATH = path.join(process.cwd(), 'src/lib/server/pdf-layout/model/LICENSE.txt'); + +let inflight: Promise | null = null; + +async function sha256Hex(filePath: string): Promise { + const bytes = await readFile(filePath); + return createHash('sha256').update(bytes).digest('hex'); +} + +async function downloadToFile(url: string, outPath: string): Promise { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + await writeFile(outPath, bytes); +} + +function manifestEntry(filePath: string): { sha256: string; size: number } | null { + const found = manifest.files.find((entry) => entry.path === filePath); + if (!found || !found.sha256) return null; + return { + sha256: found.sha256.toLowerCase(), + size: Number(found.size), + }; +} + +async function verifyFile(pathToFile: string, manifestPath: string): Promise { + const expected = manifestEntry(manifestPath); + if (!expected) return true; + const bytes = await readFile(pathToFile); + if (Number.isFinite(expected.size) && expected.size > 0 && bytes.byteLength !== expected.size) { + return false; + } + const actual = await sha256Hex(pathToFile); + return actual === expected.sha256; +} + +async function ensureLicense(): Promise { + await copyFile(STATIC_LICENSE_PATH, LICENSE_PATH); + if (!(await verifyFile(LICENSE_PATH, 'LICENSE.txt'))) { + throw new Error('Docling model license checksum verification failed'); + } +} + +async function ensureModelInternal(): Promise { + try { + await access(MODEL_PATH); + await access(CONFIG_PATH); + if (await verifyFile(MODEL_PATH, 'model.onnx') && await verifyFile(CONFIG_PATH, 'config.json')) { + await ensureLicense(); + return MODEL_PATH; + } + } catch { + // continue + } + + await mkdir(MODEL_DIR, { recursive: true }); + const tmpPath = `${MODEL_PATH}.tmp`; + const configTmpPath = `${CONFIG_PATH}.tmp`; + const modelUrl = process.env.OPENREADER_DOCLING_MODEL_URL?.trim() || DEFAULT_MODEL_URL; + const configUrl = process.env.OPENREADER_DOCLING_CONFIG_URL?.trim() || DEFAULT_CONFIG_URL; + + await downloadToFile(modelUrl, tmpPath); + if (!(await verifyFile(tmpPath, 'model.onnx'))) { + await unlink(tmpPath).catch(() => undefined); + throw new Error('Docling model checksum verification failed'); + } + await downloadToFile(configUrl, configTmpPath); + if (!(await verifyFile(configTmpPath, 'config.json'))) { + await unlink(configTmpPath).catch(() => undefined); + throw new Error('Docling model config checksum verification failed'); + } + + await rename(tmpPath, MODEL_PATH); + await rename(configTmpPath, CONFIG_PATH); + await ensureLicense(); + return MODEL_PATH; +} + +export async function ensureModel(): Promise { + if (inflight) return inflight; + inflight = ensureModelInternal().finally(() => { + inflight = null; + }); + return inflight; +} diff --git a/src/lib/server/pdf-layout/mergeTextWithRegions.ts b/src/lib/server/pdf-layout/mergeTextWithRegions.ts new file mode 100644 index 0000000..8b56834 --- /dev/null +++ b/src/lib/server/pdf-layout/mergeTextWithRegions.ts @@ -0,0 +1,145 @@ +import type { LayoutRegion, PdfTextItem } from '@/lib/server/pdf-layout/types'; + +const NON_TEXT_REGION_LABELS = new Set(['picture', 'table']); +const TEXT_ASSIGNABLE_LABELS = new Set([ + 'title', + 'section-header', + 'paragraph', + 'list-item', + 'caption', + 'page-header', + 'page-footer', + 'footnote', + 'formula', +]); + +function centroid(item: PdfTextItem): { x: number; y: number } { + return { + x: item.x + item.width / 2, + y: item.y + item.height / 2, + }; +} + +function contains(region: LayoutRegion, item: PdfTextItem): boolean { + const c = centroid(item); + return c.x >= region.bbox[0] && c.x <= region.bbox[2] && c.y >= region.bbox[1] && c.y <= region.bbox[3]; +} + +function sortReadingOrder(items: PdfTextItem[]): PdfTextItem[] { + const tolerance = 2; + return [...items].sort((a, b) => { + if (Math.abs(a.y - b.y) <= tolerance) return a.x - b.x; + return a.y - b.y; + }); +} + +function joinText(items: PdfTextItem[]): string { + let out = ''; + let prev: PdfTextItem | null = null; + for (const item of items) { + if (!prev) { + out += item.text; + prev = item; + continue; + } + const prevEndX = prev.x + prev.width; + const gap = item.x - prevEndX; + const lineJump = item.y - prev.y; + const lineBreak = lineJump > Math.max(2, Math.min(prev.height, item.height) * 0.6); + const avgCharWidth = item.width / Math.max(1, item.text.length); + const needsSpace = lineBreak || gap > Math.max(avgCharWidth * 0.3, 2); + out += needsSpace ? ` ${item.text}` : item.text; + prev = item; + } + return out.replace(/\s+/g, ' ').trim(); +} + +function regionArea(region: LayoutRegion): number { + return Math.max(1, (region.bbox[2] - region.bbox[0]) * (region.bbox[3] - region.bbox[1])); +} + +function regionScore(region: LayoutRegion): number { + return Number.isFinite(region.confidence) ? Number(region.confidence) : 0; +} + +export interface RegionTextBlock { + region: LayoutRegion; + text: string; + items: PdfTextItem[]; + sourceOrder: number; +} + +export function mergeTextWithRegions(regions: LayoutRegion[], textItems: PdfTextItem[]): RegionTextBlock[] { + const sourceIndex = new Map(); + for (let i = 0; i < textItems.length; i += 1) { + sourceIndex.set(textItems[i]!, i); + } + + const chunkSourceOrder = (items: PdfTextItem[]): number => { + let min = Number.POSITIVE_INFINITY; + for (const item of items) { + const index = sourceIndex.get(item); + if (typeof index === 'number' && index < min) min = index; + } + return Number.isFinite(min) ? min : Number.MAX_SAFE_INTEGER; + }; + + const assignableRegions = regions + .map((region, index) => ({ region, index })) + .filter(({ region }) => TEXT_ASSIGNABLE_LABELS.has(region.label)); + const assignedByRegion = new Map(); + + for (const item of textItems) { + const candidates = assignableRegions.filter(({ region }) => contains(region, item)); + if (candidates.length === 0) continue; + + candidates.sort((a, b) => { + const scoreDelta = regionScore(b.region) - regionScore(a.region); + if (Math.abs(scoreDelta) > 1e-6) return scoreDelta; + return regionArea(a.region) - regionArea(b.region); + }); + + const winner = candidates[0]; + const list = assignedByRegion.get(winner.index) ?? []; + list.push(item); + assignedByRegion.set(winner.index, list); + } + + const out: RegionTextBlock[] = []; + + for (const [regionIndex, assignedItems] of assignedByRegion.entries()) { + const region = regions[regionIndex]; + if (!region) continue; + if (assignedItems.length === 0) continue; + const ordered = sortReadingOrder(assignedItems); + const text = joinText(ordered); + if (!text) continue; + + out.push({ + region, + text, + items: ordered, + sourceOrder: chunkSourceOrder(ordered), + }); + } + + for (const region of regions) { + if (!NON_TEXT_REGION_LABELS.has(region.label)) continue; + out.push({ + region, + text: '', + items: [], + sourceOrder: Number.MAX_SAFE_INTEGER, + }); + } + + out.sort((a, b) => { + if (a.sourceOrder !== b.sourceOrder) return a.sourceOrder - b.sourceOrder; + const ay = a.region.bbox[1]; + const by = b.region.bbox[1]; + if (Math.abs(ay - by) <= 2) return a.region.bbox[0] - b.region.bbox[0]; + return ay - by; + }); + + return out; +} diff --git a/src/lib/server/pdf-layout/model/LICENSE.txt b/src/lib/server/pdf-layout/model/LICENSE.txt new file mode 100644 index 0000000..d2b2034 --- /dev/null +++ b/src/lib/server/pdf-layout/model/LICENSE.txt @@ -0,0 +1,3 @@ +Docling layout model assets are distributed under Apache-2.0 / CDLA terms by the model publisher. +See the upstream release for the authoritative license text: +https://huggingface.co/ds4sd/docling-layout-heron diff --git a/src/lib/server/pdf-layout/model/manifest.json b/src/lib/server/pdf-layout/model/manifest.json new file mode 100644 index 0000000..c688e28 --- /dev/null +++ b/src/lib/server/pdf-layout/model/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "docling-layout-heron", + "version": "docling-project/docling-layout-heron-onnx@main", + "files": [ + { + "path": "model.onnx", + "sha256": "59c81a3a2923042d85034ffc487f8f47e4854117e879aef89b2b9f728fb4922a", + "size": 171220471 + }, + { + "path": "config.json", + "sha256": "c6e67b6cdf64fa245779d0409bb994aa96e8c1790e15ec07a65843628e232180", + "size": 878 + }, + { + "path": "LICENSE.txt", + "sha256": "8f5030e085c59609746fce1010230f12b4a565abb6bdc19a2c2d3735ba1710d9", + "size": 209 + } + ] +} diff --git a/src/lib/server/pdf-layout/parsePdf.ts b/src/lib/server/pdf-layout/parsePdf.ts new file mode 100644 index 0000000..e95cc35 --- /dev/null +++ b/src/lib/server/pdf-layout/parsePdf.ts @@ -0,0 +1,153 @@ +import path from 'path'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; +import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf'; +import type { PdfTextItem } from '@/lib/server/pdf-layout/types'; +import { ensureModel } from '@/lib/server/pdf-layout/ensureModel'; +import { runLayoutModel } from '@/lib/server/pdf-layout/runLayoutModel'; +import { mergeTextWithRegions } from '@/lib/server/pdf-layout/mergeTextWithRegions'; +import { stitchCrossPageBlocks } from '@/lib/server/pdf-layout/stitchCrossPageBlocks'; +import { renderPage } from '@/lib/server/pdf-layout/renderPage'; + +interface ParsePdfInput { + documentId: string; + pdfBytes: ArrayBuffer; +} + +const LAYOUT_RENDER_SCALE = 1.5; + +function normalizeTextItems(items: TextItem[], pageHeight: number): PdfTextItem[] { + return items + .filter((item) => typeof item.str === 'string' && item.str.trim().length > 0) + .map((item) => { + const x = Number(item.transform[4] ?? 0); + const width = Math.max(0, Number(item.width ?? 0)); + const height = Math.max(1, Math.abs(Number(item.transform[3] ?? 1))); + const baselineY = Number(item.transform[5] ?? 0); + // pdf.js text transforms are in PDF user-space (origin bottom-left). + // Normalize into top-left page coordinates to match rendered image/model boxes. + const y = Math.max(0, pageHeight - baselineY - height); + return { + text: item.str, + x, + y, + width, + height, + }; + }); +} + +export async function parsePdf(input: ParsePdfInput): Promise { + await ensureModel(); + + // Keep independent byte copies for text extraction and page rendering. pdf.js + // can detach buffers passed to getDocument(). + const pdfBytesForText = new Uint8Array(input.pdfBytes).slice(); + const pdfBytesForRender = new Uint8Array(input.pdfBytes).slice(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + if (pdfjs.GlobalWorkerOptions) { + pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; + pdfjs.GlobalWorkerOptions.workerPort = null; + } + const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + const standardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`; + + const loadingTask = pdfjs.getDocument({ + data: pdfBytesForText, + useWorkerFetch: false, + standardFontDataUrl, + isEvalSupported: false, + }); + const pdf = await loadingTask.promise; + + try { + const pages: ParsedPdfPage[] = []; + let nextBlockId = 1; + let sawText = false; + + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale: 1.0 }); + const textContent = await page.getTextContent(); + const textItems = normalizeTextItems( + textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item), + viewport.height, + ); + + if (textItems.length > 0) sawText = true; + + const rendered = await renderPage({ + pdfBytes: pdfBytesForRender.buffer.slice( + pdfBytesForRender.byteOffset, + pdfBytesForRender.byteOffset + pdfBytesForRender.byteLength, + ), + pageNumber, + scale: LAYOUT_RENDER_SCALE, + }); + const scaleX = rendered.width / Math.max(1, viewport.width); + const scaleY = rendered.height / Math.max(1, viewport.height); + const layoutTextItems = textItems.map((item) => ({ + ...item, + x: item.x * scaleX, + y: item.y * scaleY, + width: item.width * scaleX, + height: item.height * scaleY, + })); + const regions = await runLayoutModel({ + pageWidth: rendered.width, + pageHeight: rendered.height, + textItems: layoutTextItems, + pagePng: rendered.png, + }); + const merged = mergeTextWithRegions(regions, layoutTextItems); + if (textItems.length > 0 && merged.length === 0) { + throw new Error(`layout-merge-empty: page=${pageNumber} regions=${regions.length}`); + } + + const blocks = merged + .map((entry, readingOrder) => ({ + id: `b${String(nextBlockId++).padStart(4, '0')}`, + kind: entry.region.label, + fragments: [{ + page: pageNumber, + bbox: [ + entry.region.bbox[0] / scaleX, + entry.region.bbox[1] / scaleY, + entry.region.bbox[2] / scaleX, + entry.region.bbox[3] / scaleY, + ] as [number, number, number, number], + text: entry.text, + readingOrder, + ...(typeof entry.region.confidence === 'number' ? { modelConfidence: entry.region.confidence } : {}), + }], + text: entry.text, + })); + + pages.push({ + pageNumber, + width: viewport.width, + height: viewport.height, + blocks, + }); + } + + if (!sawText) { + throw new Error('no-text-layer'); + } + + const doc: ParsedPdfDocument = { + schemaVersion: 1, + documentId: input.documentId, + parserVersion: 'docling-layout-heron@v1+pdfjs@4.8.69', + parsedAt: Date.now(), + pages, + }; + + return stitchCrossPageBlocks(doc); + } finally { + await pdf.destroy().catch(() => undefined); + await loadingTask.destroy().catch(() => undefined); + } +} diff --git a/src/lib/server/pdf-layout/renderPage.ts b/src/lib/server/pdf-layout/renderPage.ts new file mode 100644 index 0000000..0556176 --- /dev/null +++ b/src/lib/server/pdf-layout/renderPage.ts @@ -0,0 +1,142 @@ +import path from 'path'; +import type { Canvas } from '@napi-rs/canvas'; + +type CanvasRuntime = { + DOMMatrixCtor: unknown; + Path2DCtor: unknown; + createCanvasFn: (width: number, height: number) => Canvas; +}; + +let canvasRuntimePromise: Promise | null = null; + +async function loadCanvasRuntime(): Promise { + if (!canvasRuntimePromise) { + canvasRuntimePromise = (async () => { + const mod = await import('@napi-rs/canvas'); + const namespace = mod as Record; + const fallback = (namespace.default ?? {}) as Record; + + const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as + | ((width: number, height: number) => Canvas) + | undefined; + const DOMMatrixCtor = namespace.DOMMatrix ?? fallback.DOMMatrix; + const Path2DCtor = namespace.Path2D ?? fallback.Path2D; + + if (typeof createCanvasFn !== 'function') { + throw new Error( + `Canvas runtime missing createCanvas export (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + if (!DOMMatrixCtor || !Path2DCtor) { + throw new Error( + `Canvas runtime missing DOMMatrix/Path2D exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + + return { + DOMMatrixCtor, + Path2DCtor, + createCanvasFn, + }; + })(); + } + return canvasRuntimePromise; +} + +function ensureNodeCanvasGlobals(runtime: CanvasRuntime): void { + const g = globalThis as Record; + if (typeof g.DOMMatrix === 'undefined') g.DOMMatrix = runtime.DOMMatrixCtor; + if (typeof g.Path2D === 'undefined') g.Path2D = runtime.Path2DCtor; +} + +interface RenderInput { + pdfBytes: ArrayBuffer; + pageNumber: number; + scale?: number; +} + +function createPdfjsCanvasFactory(runtime: CanvasRuntime) { + return class OpenReaderCanvasFactory { + create(width: number, height: number) { + const canvas = runtime.createCanvasFn(Math.max(1, Math.floor(width)), Math.max(1, Math.floor(height))); + return { + canvas, + context: canvas.getContext('2d') as unknown as CanvasRenderingContext2D, + }; + } + + reset(target: { canvas: Canvas; context: CanvasRenderingContext2D }, width: number, height: number): void { + target.canvas.width = Math.max(1, Math.floor(width)); + target.canvas.height = Math.max(1, Math.floor(height)); + } + + destroy(target: { canvas: Canvas; context: CanvasRenderingContext2D }): void { + target.canvas.width = 0; + target.canvas.height = 0; + // @ts-expect-error pdf.js expects these nulled on destroy + target.canvas = null; + // @ts-expect-error pdf.js expects these nulled on destroy + target.context = null; + } + }; +} + +export async function renderPage({ pdfBytes, pageNumber, scale = 1.5 }: RenderInput): Promise<{ + width: number; + height: number; + png: Buffer; +}> { + // pdf.js may detach the provided ArrayBuffer. Work with an isolated copy so + // callers can safely reuse their original bytes across pages/calls. + const isolatedBytes = new Uint8Array(pdfBytes).slice(); + + const canvasRuntime = await loadCanvasRuntime(); + ensureNodeCanvasGlobals(canvasRuntime); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + + if (pdfjs.GlobalWorkerOptions) { + pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; + pdfjs.GlobalWorkerOptions.workerPort = null; + } + + const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + const standardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`; + + const loadingTask = pdfjs.getDocument({ + data: isolatedBytes, + useWorkerFetch: false, + standardFontDataUrl, + isEvalSupported: false, + // Ensure pdf.js transport uses our canvas backend in Node/Next runtime. + CanvasFactory: createPdfjsCanvasFactory(canvasRuntime), + }); + + const pdf = await loadingTask.promise; + try { + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale }); + const width = Math.max(1, Math.floor(viewport.width)); + const height = Math.max(1, Math.floor(viewport.height)); + const canvas = canvasRuntime.createCanvasFn(width, height); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, height); + + const renderTask = page.render({ + canvasContext: ctx as unknown as CanvasRenderingContext2D, + viewport, + intent: 'display', + }); + await renderTask.promise; + return { + width, + height, + png: canvas.toBuffer('image/png'), + }; + } finally { + await pdf.destroy().catch(() => undefined); + await loadingTask.destroy().catch(() => undefined); + } +} diff --git a/src/lib/server/pdf-layout/runLayoutModel.ts b/src/lib/server/pdf-layout/runLayoutModel.ts new file mode 100644 index 0000000..608de35 --- /dev/null +++ b/src/lib/server/pdf-layout/runLayoutModel.ts @@ -0,0 +1,241 @@ +import * as ort from 'onnxruntime-node'; +import { readFile } from 'fs/promises'; +import path from 'path'; +import type { LayoutRegion, PdfTextItem } from '@/lib/server/pdf-layout/types'; +import { ensureModel } from '@/lib/server/pdf-layout/ensureModel'; + +interface RunLayoutInput { + pageWidth: number; + pageHeight: number; + textItems: PdfTextItem[]; + pagePng: Buffer; +} + +const INPUT_SIZE = 640; +const MIN_SCORE = 0.6; + +const LABEL_MAP: Record = { + caption: 'caption', + footnote: 'footnote', + formula: 'formula', + list_item: 'list-item', + page_footer: 'page-footer', + page_header: 'page-header', + picture: 'picture', + section_header: 'section-header', + table: 'table', + text: 'paragraph', + title: 'title', + document_index: null, + code: null, + checkbox_selected: null, + checkbox_unselected: null, + form: null, + key_value_region: null, +}; + +const MIN_REGION_SIZE: Partial> = { + paragraph: { minWidth: 24, minHeight: 14 }, + 'section-header': { minWidth: 24, minHeight: 14 }, + title: { minWidth: 24, minHeight: 14 }, + 'list-item': { minWidth: 18, minHeight: 12 }, + caption: { minWidth: 18, minHeight: 10 }, + footnote: { minWidth: 18, minHeight: 10 }, + 'page-header': { minWidth: 18, minHeight: 10 }, + 'page-footer': { minWidth: 18, minHeight: 10 }, +}; + +let sessionPromise: Promise | null = null; +let idToLabelPromise: Promise> | null = null; +let canvasFnsPromise: Promise<{ + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }; + loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>; +}> | null = null; + +async function getCanvasFns(): Promise<{ + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }; + loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>; +}> { + if (!canvasFnsPromise) { + canvasFnsPromise = (async () => { + const mod = await import('@napi-rs/canvas'); + const namespace = mod as Record; + const fallback = (namespace.default ?? {}) as Record; + const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as + | ((width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }) + | undefined; + const loadImageFn = (namespace.loadImage ?? fallback.loadImage) as + | ((src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>) + | undefined; + + if (typeof createCanvasFn !== 'function' || typeof loadImageFn !== 'function') { + throw new Error( + `Canvas runtime missing createCanvas/loadImage exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + + return { createCanvasFn, loadImageFn }; + })(); + } + return canvasFnsPromise; +} + +async function getSession(): Promise { + if (!sessionPromise) { + sessionPromise = (async () => { + const modelPath = await ensureModel(); + return ort.InferenceSession.create(modelPath, { + executionProviders: ['cpu'], + graphOptimizationLevel: 'all', + }); + })(); + } + return sessionPromise; +} + +async function getIdToLabel(): Promise> { + if (!idToLabelPromise) { + idToLabelPromise = (async () => { + const modelPath = await ensureModel(); + const configPath = path.join(path.dirname(modelPath), 'docling-layout-heron.config.json'); + const raw = await readFile(configPath, 'utf8'); + const parsed = JSON.parse(raw) as { id2label?: Record }; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed.id2label ?? {})) { + const n = Number(key); + if (Number.isFinite(n)) out[n] = value; + } + return out; + })(); + } + return idToLabelPromise; +} + +function preprocessLetterboxed( + image: CanvasImageSource, + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }, +): { + tensor: ort.Tensor; + scale: number; + padX: number; + padY: number; +} { + const sourceWidth = Math.max(1, Number((image as { width?: number }).width ?? INPUT_SIZE)); + const sourceHeight = Math.max(1, Number((image as { height?: number }).height ?? INPUT_SIZE)); + const scale = Math.min(INPUT_SIZE / sourceWidth, INPUT_SIZE / sourceHeight); + const drawWidth = Math.max(1, Math.round(sourceWidth * scale)); + const drawHeight = Math.max(1, Math.round(sourceHeight * scale)); + const padX = Math.floor((INPUT_SIZE - drawWidth) / 2); + const padY = Math.floor((INPUT_SIZE - drawHeight) / 2); + + const canvas = createCanvasFn(INPUT_SIZE, INPUT_SIZE); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, INPUT_SIZE, INPUT_SIZE); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(image, padX, padY, drawWidth, drawHeight); + + const imageData = ctx.getImageData(0, 0, INPUT_SIZE, INPUT_SIZE); + const data = new Uint8Array(1 * 3 * INPUT_SIZE * INPUT_SIZE); + for (let y = 0; y < INPUT_SIZE; y += 1) { + for (let x = 0; x < INPUT_SIZE; x += 1) { + const pixelIndex = (y * INPUT_SIZE + x) * 4; + const chwIndex = y * INPUT_SIZE + x; + data[0 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex]; + data[1 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex + 1]; + data[2 * INPUT_SIZE * INPUT_SIZE + chwIndex] = imageData.data[pixelIndex + 2]; + } + } + + return { + tensor: new ort.Tensor('uint8', data, [1, 3, INPUT_SIZE, INPUT_SIZE]), + scale, + padX, + padY, + }; +} + +function clampBox( + bbox: [number, number, number, number], + pageWidth: number, + pageHeight: number, +): [number, number, number, number] | null { + const x0 = Math.max(0, Math.min(pageWidth, bbox[0])); + const y0 = Math.max(0, Math.min(pageHeight, bbox[1])); + const x1 = Math.max(0, Math.min(pageWidth, bbox[2])); + const y1 = Math.max(0, Math.min(pageHeight, bbox[3])); + if (x1 <= x0 || y1 <= y0) return null; + return [x0, y0, x1, y1]; +} + +export async function runLayoutModel(input: RunLayoutInput): Promise { + const { pageWidth, pageHeight, textItems, pagePng } = input; + if (!textItems.length) return []; + if (!pagePng || pagePng.byteLength === 0) { + throw new Error('layout-render-missing-page-image'); + } + + try { + const [session, idToLabel, canvasFns] = await Promise.all([getSession(), getIdToLabel(), getCanvasFns()]); + const pageImage = await canvasFns.loadImageFn(pagePng); + const preprocess = preprocessLetterboxed(pageImage, canvasFns.createCanvasFn); + const targetSizes = new ort.Tensor('int64', new BigInt64Array([BigInt(INPUT_SIZE), BigInt(INPUT_SIZE)]), [1, 2]); + const output = await session.run({ + images: preprocess.tensor, + orig_target_sizes: targetSizes, + }); + + const labels = output.labels?.data as BigInt64Array | Int32Array | undefined; + const boxes = output.boxes?.data as Float32Array | undefined; + const scores = output.scores?.data as Float32Array | undefined; + if (!labels || !boxes || !scores) return []; + + const regions: LayoutRegion[] = []; + const count = Math.min(scores.length, Math.floor(boxes.length / 4), labels.length); + for (let i = 0; i < count; i += 1) { + const score = scores[i]; + if (!Number.isFinite(score) || score < MIN_SCORE) continue; + + const rawLabel = Number(labels[i]); + const labelName = idToLabel[rawLabel]; + if (!labelName) continue; + const mapped = LABEL_MAP[labelName]; + if (!mapped) continue; + + const modelBox: [number, number, number, number] = [ + boxes[i * 4 + 0], + boxes[i * 4 + 1], + boxes[i * 4 + 2], + boxes[i * 4 + 3], + ]; + const rawBox: [number, number, number, number] = [ + (modelBox[0] - preprocess.padX) / preprocess.scale, + (modelBox[1] - preprocess.padY) / preprocess.scale, + (modelBox[2] - preprocess.padX) / preprocess.scale, + (modelBox[3] - preprocess.padY) / preprocess.scale, + ]; + const clamped = clampBox(rawBox, pageWidth, pageHeight); + if (!clamped) continue; + + const sizeRule = MIN_REGION_SIZE[mapped]; + if (sizeRule) { + const width = clamped[2] - clamped[0]; + const height = clamped[3] - clamped[1]; + if (width < sizeRule.minWidth || height < sizeRule.minHeight) continue; + } + + regions.push({ + bbox: clamped, + label: mapped, + confidence: score, + }); + } + + return regions.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)); + } catch (error) { + throw new Error( + `layout-model-inference-failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts b/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts new file mode 100644 index 0000000..efd8a54 --- /dev/null +++ b/src/lib/server/pdf-layout/stitchCrossPageBlocks.ts @@ -0,0 +1,80 @@ +import type { ParsedPdfDocument, ParsedPdfBlock } from '@/types/parsed-pdf'; + +function stripTrailingClosers(text: string): string { + return text.trim().replace(/[\"'”’\]\)]+$/g, ''); +} + +function isSentenceTerminal(text: string): boolean { + return /[.!?]$/.test(stripTrailingClosers(text)); +} + +function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean { + if (!['paragraph', 'list-item'].includes(a.kind)) return false; + if (a.kind !== b.kind) return false; + if (isSentenceTerminal(a.text)) return false; + const next = b.text.trim(); + if (!next) return false; + if (/^[A-Z]/.test(next)) return false; + return true; +} + +const HARD_BOUNDARY_KINDS = new Set(['section-header', 'title']); + +function findTailCandidateIndex(blocks: ParsedPdfBlock[]): number { + for (let i = blocks.length - 1; i >= 0; i -= 1) { + const block = blocks[i]; + if (!block || !block.text.trim()) continue; + if (block.kind === 'paragraph' || block.kind === 'list-item') return i; + } + return -1; +} + +function findHeadCandidateIndex(blocks: ParsedPdfBlock[]): number { + for (let i = 0; i < blocks.length; i += 1) { + const block = blocks[i]; + if (!block || !block.text.trim()) continue; + if (block.kind === 'paragraph' || block.kind === 'list-item') return i; + } + return -1; +} + +function hasHardBoundaryBetween( + pageBlocks: ParsedPdfBlock[], + startInclusive: number, + endExclusive: number, +): boolean { + for (let i = startInclusive; i < endExclusive; i += 1) { + const block = pageBlocks[i]; + if (block && HARD_BOUNDARY_KINDS.has(block.kind)) return true; + } + return false; +} + +export function stitchCrossPageBlocks(doc: ParsedPdfDocument): ParsedPdfDocument { + const pages = doc.pages.map((page) => ({ ...page, blocks: page.blocks.map((b) => ({ ...b, fragments: b.fragments.map((f) => ({ ...f })) })) })); + + for (let i = 0; i < pages.length - 1; i += 1) { + const page = pages[i]; + const next = pages[i + 1]; + const tailIndex = findTailCandidateIndex(page.blocks); + const headIndex = findHeadCandidateIndex(next.blocks); + if (tailIndex < 0 || headIndex < 0) continue; + + if (hasHardBoundaryBetween(page.blocks, tailIndex + 1, page.blocks.length)) continue; + if (hasHardBoundaryBetween(next.blocks, 0, headIndex)) continue; + + const tail = page.blocks[tailIndex]; + const head = next.blocks[headIndex]; + if (!tail || !head) continue; + if (!canStitch(tail, head)) continue; + + tail.fragments.push(...head.fragments); + tail.text = `${tail.text} ${head.text}`.replace(/\s+/g, ' ').trim(); + next.blocks.splice(headIndex, 1); + } + + return { + ...doc, + pages, + }; +} diff --git a/src/lib/server/pdf-layout/types.ts b/src/lib/server/pdf-layout/types.ts new file mode 100644 index 0000000..c2e1079 --- /dev/null +++ b/src/lib/server/pdf-layout/types.ts @@ -0,0 +1,15 @@ +import type { ParsedPdfBlockKind } from '@/types/parsed-pdf'; + +export interface PdfTextItem { + text: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface LayoutRegion { + bbox: [number, number, number, number]; + label: ParsedPdfBlockKind; + confidence?: number; +} diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts index b31eee6..87889d8 100644 --- a/src/lib/server/runtime-config.ts +++ b/src/lib/server/runtime-config.ts @@ -6,8 +6,11 @@ import { type RuntimeConfigKey, type RuntimeConfigSource, } from '@/lib/server/admin/settings'; +import { isComputeAvailable } from '@/lib/server/compute'; -export type ResolvedRuntimeConfig = RuntimeConfig; +export type ResolvedRuntimeConfig = RuntimeConfig & { + computeAvailable: boolean; +}; function assertServerRuntime(caller: string): void { if (typeof window !== 'undefined') { @@ -23,7 +26,11 @@ function assertServerRuntime(caller: string): void { export async function getResolvedRuntimeConfig(): Promise { assertServerRuntime('getResolvedRuntimeConfig'); await ensureAdminSeed(); - return getRuntimeConfig(); + const values = await getRuntimeConfig(); + return { + ...values, + computeAvailable: isComputeAvailable(), + }; } export async function getResolvedRuntimeConfigWithSources(): Promise<{ diff --git a/src/lib/server/storage/s3.ts b/src/lib/server/storage/s3.ts index df482ab..70b63d3 100644 --- a/src/lib/server/storage/s3.ts +++ b/src/lib/server/storage/s3.ts @@ -90,6 +90,8 @@ export function getS3Client(): S3Client { region: config.region, endpoint: config.endpoint, forcePathStyle: config.forcePathStyle, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, @@ -111,6 +113,8 @@ export function getS3ProxyClient(): S3Client { region: config.region, endpoint: loopbackEndpoint(config.endpoint), forcePathStyle: config.forcePathStyle, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, diff --git a/src/lib/server/tts/segments-cache.ts b/src/lib/server/tts/segments-cache.ts new file mode 100644 index 0000000..d893f5c --- /dev/null +++ b/src/lib/server/tts/segments-cache.ts @@ -0,0 +1,82 @@ +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; +import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; +import type { ReaderType } from '@/types/user-state'; + +type ClearTtsSegmentCacheInput = { + userId: string; + documentId: string; + documentVersion?: number; + readerType?: ReaderType; +}; + +export type ClearTtsSegmentCacheResult = { + deletedSegments: number; + requestedAudioObjects: number; + deletedAudioObjects: number; + warning?: string; +}; + +export async function clearTtsSegmentCache( + input: ClearTtsSegmentCacheInput, +): Promise { + const conditions = [ + eq(ttsSegmentEntries.userId, input.userId), + eq(ttsSegmentEntries.documentId, input.documentId), + ]; + + if (typeof input.documentVersion === 'number' && Number.isFinite(input.documentVersion)) { + conditions.push(eq(ttsSegmentEntries.documentVersion, Math.floor(input.documentVersion))); + } + if (input.readerType) { + conditions.push(eq(ttsSegmentEntries.readerType, input.readerType)); + } + + const rows = (await db + .select({ + segmentId: ttsSegmentVariants.segmentId, + audioKey: ttsSegmentVariants.audioKey, + }) + .from(ttsSegmentVariants) + .innerJoin( + ttsSegmentEntries, + and( + eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId), + eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId), + ), + ) + .where(and(...conditions))) as Array<{ segmentId: string; audioKey: string | null }>; + + await db.delete(ttsSegmentEntries).where(and(...conditions)); + + const audioKeys = rows + .map((row) => row.audioKey) + .filter((key): key is string => Boolean(key)); + const uniqueAudioKeys = Array.from(new Set(audioKeys)); + + let deletedAudioObjects = 0; + let warning: string | undefined; + if (uniqueAudioKeys.length > 0) { + try { + deletedAudioObjects = await deleteTtsSegmentAudioObjects(uniqueAudioKeys); + if (deletedAudioObjects < uniqueAudioKeys.length) { + warning = `Deleted ${deletedAudioObjects} of ${uniqueAudioKeys.length} audio objects.`; + } + } catch (error) { + warning = error instanceof Error ? error.message : 'Failed deleting some audio objects'; + console.warn('Failed clearing some TTS segment audio objects:', { + documentId: input.documentId, + userId: input.userId, + error: warning, + }); + } + } + + return { + deletedSegments: rows.length, + requestedAudioObjects: uniqueAudioKeys.length, + deletedAudioObjects, + ...(warning ? { warning } : {}), + }; +} diff --git a/src/lib/server/tts/segments.ts b/src/lib/server/tts/segments.ts index 9da16d2..d1bb3c7 100644 --- a/src/lib/server/tts/segments.ts +++ b/src/lib/server/tts/segments.ts @@ -81,6 +81,9 @@ export function normalizeLocator(locator: TTSSegmentLocator | undefined): TTSSeg return { readerType: 'pdf', page: Math.max(1, Math.floor(locator.page)), + ...(typeof locator.blockId === 'string' && locator.blockId.trim() + ? { blockId: locator.blockId.trim() } + : {}), }; } if (locator.readerType === 'html') { diff --git a/src/lib/shared/document-settings.ts b/src/lib/shared/document-settings.ts new file mode 100644 index 0000000..d22ef6c --- /dev/null +++ b/src/lib/shared/document-settings.ts @@ -0,0 +1,76 @@ +import { + DEFAULT_DOCUMENT_SETTINGS, + type DocumentSettings, +} from '@/types/document-settings'; +import type { ParsedPdfBlockKind } from '@/types/parsed-pdf'; + +const PDF_BLOCK_KINDS: ParsedPdfBlockKind[] = [ + 'title', + 'section-header', + 'paragraph', + 'list-item', + 'caption', + 'table', + 'picture', + 'page-header', + 'page-footer', + 'footnote', + 'formula', +]; + +function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] { + if (!Array.isArray(value)) return [...(DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? [])]; + const allow = new Set(PDF_BLOCK_KINDS); + const out: ParsedPdfBlockKind[] = []; + for (const entry of value) { + if (typeof entry !== 'string') continue; + if (!allow.has(entry as ParsedPdfBlockKind)) continue; + out.push(entry as ParsedPdfBlockKind); + } + return Array.from(new Set(out)); +} + +type PdfMargins = { header: number; footer: number; left: number; right: number }; + +function normalizeMargins(value: unknown): PdfMargins | undefined { + if (!value || typeof value !== 'object') return undefined; + const rec = value as Record; + const header = Number(rec.header); + const footer = Number(rec.footer); + const left = Number(rec.left); + const right = Number(rec.right); + if (![header, footer, left, right].every((n) => Number.isFinite(n))) return undefined; + return { header, footer, left, right }; +} + +export function mergeDocumentSettings( + defaults: DocumentSettings = DEFAULT_DOCUMENT_SETTINGS, + stored: unknown, +): DocumentSettings { + const base: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: [...(defaults.pdf?.skipBlockKinds ?? [])], + chaptersFromSections: defaults.pdf?.chaptersFromSections ?? true, + ...(defaults.pdf?.margins ? { margins: defaults.pdf.margins } : {}), + }, + }; + + if (!stored || typeof stored !== 'object') return base; + const rec = stored as Record; + const pdf = rec.pdf; + if (!pdf || typeof pdf !== 'object') return base; + const pdfRec = pdf as Record; + + return { + schemaVersion: 1, + pdf: { + skipBlockKinds: normalizeSkipKinds(pdfRec.skipBlockKinds), + chaptersFromSections: + typeof pdfRec.chaptersFromSections === 'boolean' + ? pdfRec.chaptersFromSections + : (base.pdf?.chaptersFromSections ?? true), + ...(normalizeMargins(pdfRec.margins) ? { margins: normalizeMargins(pdfRec.margins) } : {}), + }, + }; +} diff --git a/src/lib/shared/tts-locator.ts b/src/lib/shared/tts-locator.ts index 54db024..4c7eb15 100644 --- a/src/lib/shared/tts-locator.ts +++ b/src/lib/shared/tts-locator.ts @@ -70,7 +70,10 @@ export function locatorIdentityKey(locator: TTSSegmentLocator | null): string { return `epub:${locator.spineIndex}:${locator.spineHref}:${locator.charOffset}`; } if (isPdfLocator(locator)) { - return `pdf:${Math.floor(locator.page)}`; + const blockPart = typeof locator.blockId === 'string' && locator.blockId.trim() + ? `:${locator.blockId.trim()}` + : ''; + return `pdf:${Math.floor(locator.page)}${blockPart}`; } if (isHtmlLocator(locator)) { return `html:${locator.location}`; @@ -112,7 +115,11 @@ export function compareSegmentLocators( return a.spineHref.localeCompare(b.spineHref); } if (isPdfLocator(a) && isPdfLocator(b)) { - return Math.floor(a.page) - Math.floor(b.page); + const pageCmp = Math.floor(a.page) - Math.floor(b.page); + if (pageCmp !== 0) return pageCmp; + const aBlock = typeof a.blockId === 'string' ? a.blockId : ''; + const bBlock = typeof b.blockId === 'string' ? b.blockId : ''; + return aBlock.localeCompare(bBlock); } if (isHtmlLocator(a) && isHtmlLocator(b)) { // When both locations look like positive integers (HTML reader blocks), diff --git a/src/lib/shared/tts-segment-plan.ts b/src/lib/shared/tts-segment-plan.ts index e2aa1ba..de3fd0c 100644 --- a/src/lib/shared/tts-segment-plan.ts +++ b/src/lib/shared/tts-segment-plan.ts @@ -37,6 +37,7 @@ export interface CanonicalTtsSegmentPlanOptions { readerType?: ReaderType; maxBlockLength?: number; keyPrefix?: string; + enforceSourceBoundaries?: boolean; } interface PreparedSourceUnit { @@ -185,6 +186,7 @@ export function planCanonicalTtsSegments( options: CanonicalTtsSegmentPlanOptions = {}, ): CanonicalTtsSegmentPlan { const readerType = options.readerType ?? 'pdf'; + const enforceSourceBoundaries = Boolean(options.enforceSourceBoundaries); const keyPrefix = options.keyPrefix ?? TTS_SEGMENT_PLAN_VERSION; const preparedSources: PreparedSourceUnit[] = []; const textParts: string[] = []; @@ -263,6 +265,41 @@ export function planCanonicalTtsSegments( spansSourceBoundary: false, }); } else { + const splitAcrossSourceBoundaries = () => { + const ownerIdx = preparedSources.indexOf(ownerSource); + const endIdx = preparedSources.indexOf(endSource); + if (ownerIdx < 0 || endIdx < 0) return; + + let subStart = startOffset; + for (let srcIdx = ownerIdx; srcIdx <= endIdx; srcIdx += 1) { + const source = preparedSources[srcIdx]; + const subEnd = srcIdx < endIdx ? source.endOffset : endOffset; + if (subEnd <= subStart) continue; + + const subText = canonicalText.slice(subStart, subEnd).trim(); + if (!subText) { + subStart = subEnd; + continue; + } + + const nextSource = srcIdx < endIdx ? preparedSources[srcIdx + 1] : null; + const subStartAnchor = anchorForOffset(source, subStart); + const subEndAnchor = anchorForOffset(source, subEnd); + const ordinal = segments.length; + segments.push({ + key: buildSegmentKey(keyPrefix, subText), + ordinal, + text: subText, + ownerSourceKey: source.sourceKey, + ownerLocator: source.locator, + startAnchor: subStartAnchor, + endAnchor: subEndAnchor, + spansSourceBoundary: false, + }); + subStart = nextSource ? nextSource.startOffset : subEnd; + } + }; + // Block spans one or more source boundaries. Decide whether to keep it // as a single boundary-spanning segment or to split it at each boundary. // @@ -286,6 +323,12 @@ export function planCanonicalTtsSegments( // continuation, so it should be filtered out of the current page's // segments. if (ownerSource.locator !== null) { + if (enforceSourceBoundaries) { + // PDF source units are logical layout blocks; never allow a segment + // to cross a block boundary. + splitAcrossSourceBoundaries(); + continue; + } // Both sources carry locators → original unified boundary behavior. const ordinal = segments.length; const startAnchor = anchorForOffset(ownerSource, startOffset); @@ -318,34 +361,7 @@ export function planCanonicalTtsSegments( if (isCleanBoundary) { // Clean boundary → split at each source boundary. - let subStart = startOffset; - for (let srcIdx = ownerIdx; srcIdx <= endIdx; srcIdx += 1) { - const source = preparedSources[srcIdx]; - const subEnd = srcIdx < endIdx ? source.endOffset : endOffset; - if (subEnd <= subStart) continue; - - const subText = canonicalText.slice(subStart, subEnd).trim(); - if (!subText) { - subStart = subEnd; - continue; - } - - const nextSource = srcIdx < endIdx ? preparedSources[srcIdx + 1] : null; - const subStartAnchor = anchorForOffset(source, subStart); - const subEndAnchor = anchorForOffset(source, subEnd); - const ordinal = segments.length; - segments.push({ - key: buildSegmentKey(keyPrefix, subText), - ordinal, - text: subText, - ownerSourceKey: source.sourceKey, - ownerLocator: source.locator, - startAnchor: subStartAnchor, - endAnchor: subEndAnchor, - spansSourceBoundary: false, - }); - subStart = nextSource ? nextSource.startOffset : subEnd; - } + splitAcrossSourceBoundaries(); } else { // Overlapping sentence → keep unified, owned by the context-only // source. This segment will be filtered out of the current page's diff --git a/src/types/client.ts b/src/types/client.ts index 5ce3e82..aed3042 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -107,6 +107,8 @@ export interface TTSSegmentLocator { readerType?: TTSReaderType; // PDF / legacy page?: number; + // PDF block-level locator (structured parser path) + blockId?: string; // HTML / legacy EPUB CFI (kept for in-flight drafts; not persisted for EPUB) location?: string; // Stable EPUB coordinates diff --git a/src/types/config.ts b/src/types/config.ts index 7601b06..03881c3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -8,15 +8,6 @@ import { defaultModelForProviderType } from '@/lib/shared/tts-provider-policy'; // `window.__OPENREADER_RUNTIME_CONFIG__` (SSR-injected) on the client, and // from the built-in defaults during SSR. -function readRuntimeFlag(key: string, defaultValue: boolean): boolean { - if (typeof window === 'undefined') return defaultValue; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__; - if (!injected || typeof injected !== 'object') return defaultValue; - const value = injected[key]; - return typeof value === 'boolean' ? value : defaultValue; -} - function readRuntimeString(key: string, defaultValue: string): string { if (typeof window === 'undefined') return defaultValue; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -96,7 +87,6 @@ export interface AppConfigValues { * that reads through it. */ export function getAppConfigDefaults(): AppConfigValues { - const wordHighlightEnabledByDefault = readRuntimeFlag('enableWordHighlight', true); const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai'); const defaultProviderRef = runtimeProviderRef.trim(); const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown'; @@ -126,11 +116,11 @@ export function getAppConfigDefaults(): AppConfigValues { segmentPreloadSentenceLookahead: 3, ttsSegmentMaxBlockLength: 450, pdfHighlightEnabled: true, - pdfWordHighlightEnabled: wordHighlightEnabledByDefault, + pdfWordHighlightEnabled: true, epubHighlightEnabled: true, - epubWordHighlightEnabled: wordHighlightEnabledByDefault, + epubWordHighlightEnabled: true, htmlHighlightEnabled: true, - htmlWordHighlightEnabled: wordHighlightEnabledByDefault, + htmlWordHighlightEnabled: true, firstVisit: false, documentListState: { sortBy: 'name', diff --git a/src/types/document-settings.ts b/src/types/document-settings.ts new file mode 100644 index 0000000..bb591ad --- /dev/null +++ b/src/types/document-settings.ts @@ -0,0 +1,18 @@ +import type { ParsedPdfBlockKind } from '@/types/parsed-pdf'; + +export interface DocumentSettings { + schemaVersion: 1; + pdf?: { + skipBlockKinds: ParsedPdfBlockKind[]; + margins?: { header: number; footer: number; left: number; right: number }; + chaptersFromSections: boolean; + }; +} + +export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: ['page-header', 'page-footer', 'footnote'], + chaptersFromSections: true, + }, +}; diff --git a/src/types/documents.ts b/src/types/documents.ts index cae8b99..b25d8fe 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -6,6 +6,8 @@ export interface BaseDocument { size: number; lastModified: number; type: DocumentType; + parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | 'unsupported' | null; + parsedJsonKey?: string | null; scope?: 'user' | 'unclaimed'; folderId?: string; isConverting?: boolean; diff --git a/src/types/parsed-pdf.ts b/src/types/parsed-pdf.ts new file mode 100644 index 0000000..f64f126 --- /dev/null +++ b/src/types/parsed-pdf.ts @@ -0,0 +1,45 @@ +export type ParsedPdfBlockKind = + | 'title' + | 'section-header' + | 'paragraph' + | 'list-item' + | 'caption' + | 'table' + | 'picture' + | 'page-header' + | 'page-footer' + | 'footnote' + | 'formula'; + +export interface ParsedPdfBlockFragment { + page: number; + bbox: [number, number, number, number]; + text: string; + readingOrder: number; + modelConfidence?: number; +} + +export interface ParsedPdfBlock { + id: string; + kind: ParsedPdfBlockKind; + fragments: ParsedPdfBlockFragment[]; + text: string; + parentSectionId?: string; +} + +export interface ParsedPdfPage { + pageNumber: number; + width: number; + height: number; + blocks: ParsedPdfBlock[]; +} + +export interface ParsedPdfDocument { + schemaVersion: 1; + documentId: string; + parserVersion: string; + parsedAt: number; + pages: ParsedPdfPage[]; +} + +export type PdfParseStatus = 'pending' | 'running' | 'ready' | 'failed' | 'unsupported'; diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts new file mode 100644 index 0000000..d430401 --- /dev/null +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { createPdfAudiobookSourceAdapter } from '../../src/lib/client/audiobooks/adapters/pdf'; +import type { ParsedPdfDocument } from '../../src/types/parsed-pdf'; +import type { DocumentSettings } from '../../src/types/document-settings'; + +test.describe('pdf audiobook adapter', () => { + test('builds chapters from section headers and filters skipped kinds', async () => { + const parsed: ParsedPdfDocument = { + schemaVersion: 1, + documentId: 'doc-1', + parserVersion: 'test', + parsedAt: Date.now(), + pages: [ + { + pageNumber: 1, + width: 100, + height: 100, + blocks: [ + { + id: 'b1', + kind: 'section-header', + text: 'Intro', + fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Intro', readingOrder: 0 }], + }, + { + id: 'b2', + kind: 'paragraph', + text: 'Welcome text.', + fragments: [{ page: 1, bbox: [0, 60, 100, 79], text: 'Welcome text.', readingOrder: 1 }], + }, + { + id: 'b3', + kind: 'page-header', + text: 'Header line', + fragments: [{ page: 1, bbox: [0, 95, 100, 100], text: 'Header line', readingOrder: 2 }], + }, + { + id: 'b4', + kind: 'section-header', + text: 'Second', + fragments: [{ page: 1, bbox: [0, 40, 100, 50], text: 'Second', readingOrder: 3 }], + }, + { + id: 'b5', + kind: 'paragraph', + text: 'More body.', + fragments: [{ page: 1, bbox: [0, 20, 100, 39], text: 'More body.', readingOrder: 4 }], + }, + ], + }, + ], + }; + + const settings: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: ['page-header'], + chaptersFromSections: true, + }, + }; + + const adapter = createPdfAudiobookSourceAdapter({ + parsed, + settings, + margins: { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }, + smartSentenceSplitting: false, + }); + + const chapters = await adapter.prepareChapters(); + expect(chapters).toHaveLength(2); + expect(chapters[0].title).toBe('Intro'); + expect(chapters[0].text).toContain('Welcome text.'); + expect(chapters[0].text).not.toContain('Header line'); + expect(chapters[1].title).toBe('Second'); + expect(chapters[1].text).toContain('More body.'); + }); +}); diff --git a/tests/unit/pdf-build-page-text.spec.ts b/tests/unit/pdf-build-page-text.spec.ts new file mode 100644 index 0000000..e5b8ac2 --- /dev/null +++ b/tests/unit/pdf-build-page-text.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { buildPageTextFromBlocks } from '../../src/lib/client/pdf-block-text'; +import type { ParsedPdfPage } from '../../src/types/parsed-pdf'; + +test.describe('buildPageTextFromBlocks', () => { + test('filters skipped kinds and preserves reading order', () => { + const page: ParsedPdfPage = { + pageNumber: 1, + width: 100, + height: 100, + blocks: [ + { + id: 'b2', + kind: 'page-header', + text: 'Copyright Header', + fragments: [{ page: 1, bbox: [0, 90, 100, 100], text: 'Copyright Header', readingOrder: 0 }], + }, + { + id: 'b1', + kind: 'paragraph', + text: 'Body text', + fragments: [{ page: 1, bbox: [0, 20, 100, 80], text: 'Body text', readingOrder: 1 }], + }, + { + id: 'b3', + kind: 'caption', + text: 'Figure caption', + fragments: [{ page: 1, bbox: [0, 5, 100, 19], text: 'Figure caption', readingOrder: 2 }], + }, + ], + }; + + expect(buildPageTextFromBlocks(page, ['page-header'])).toBe('Body text Figure caption'); + expect(buildPageTextFromBlocks(page, ['page-header', 'caption'])).toBe('Body text'); + }); +}); diff --git a/tests/unit/pdf-merge-text-with-regions.spec.ts b/tests/unit/pdf-merge-text-with-regions.spec.ts new file mode 100644 index 0000000..09be6dc --- /dev/null +++ b/tests/unit/pdf-merge-text-with-regions.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; +import { mergeTextWithRegions } from '../../src/lib/server/pdf-layout/mergeTextWithRegions'; + +test.describe('mergeTextWithRegions', () => { + test('assigns text items to containing regions by centroid and joins in reading order', () => { + const regions = [ + { bbox: [0, 0, 100, 50] as [number, number, number, number], label: 'paragraph' as const }, + { bbox: [0, 50, 100, 100] as [number, number, number, number], label: 'caption' as const }, + ]; + + const textItems = [ + { text: 'world', x: 40, y: 20, width: 20, height: 8 }, + { text: 'hello', x: 10, y: 20, width: 20, height: 8 }, + { text: 'Figure', x: 10, y: 70, width: 24, height: 8 }, + { text: '1.2', x: 40, y: 70, width: 10, height: 8 }, + ]; + + const merged = mergeTextWithRegions(regions, textItems); + expect(merged).toHaveLength(2); + expect(merged[0].text).toBe('hello world'); + expect(merged[1].text).toBe('Figure 1.2'); + }); +}); diff --git a/tests/unit/pdf-stitch-cross-page-blocks.spec.ts b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts new file mode 100644 index 0000000..2a0ff5d --- /dev/null +++ b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; +import { stitchCrossPageBlocks } from '../../src/lib/server/pdf-layout/stitchCrossPageBlocks'; +import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../src/types/parsed-pdf'; + +function makeBlock( + id: string, + kind: ParsedPdfBlockKind, + text: string, + page: number, + readingOrder: number, +): ParsedPdfBlock { + return { + id, + kind, + text, + fragments: [{ + page, + bbox: [0, 0, 100, 10], + text, + readingOrder, + }], + }; +} + +function makeDoc(page1Blocks: ParsedPdfBlock[], page2Blocks: ParsedPdfBlock[]): ParsedPdfDocument { + return { + schemaVersion: 1, + documentId: 'doc', + parserVersion: 'test', + parsedAt: 0, + pages: [ + { pageNumber: 1, width: 100, height: 100, blocks: page1Blocks }, + { pageNumber: 2, width: 100, height: 100, blocks: page2Blocks }, + ], + }; +} + +test.describe('stitchCrossPageBlocks', () => { + test('stitches paragraph continuation across footer/header noise', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'paragraph', 'This sentence continues', 1, 0), + makeBlock('b2', 'page-footer', 'Footer text', 1, 1), + ], + [ + makeBlock('b3', 'page-header', 'Header text', 2, 0), + makeBlock('b4', 'paragraph', 'into the next page.', 2, 1), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + const page1 = stitched.pages[0]; + const page2 = stitched.pages[1]; + + expect(page1?.blocks[0]?.text).toBe('This sentence continues into the next page.'); + expect(page1?.blocks[0]?.fragments).toHaveLength(2); + expect(page2?.blocks.map((b) => b.id)).toEqual(['b3']); + }); + + test('does not stitch across section-header boundary', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'paragraph', 'This sentence continues', 1, 0), + ], + [ + makeBlock('b2', 'section-header', '2 New Section', 2, 0), + makeBlock('b3', 'paragraph', 'into the next page.', 2, 1), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + expect(stitched.pages[0]?.blocks[0]?.fragments).toHaveLength(1); + expect(stitched.pages[1]?.blocks.map((b) => b.id)).toEqual(['b2', 'b3']); + }); + + test('does not stitch when tail has sentence terminal', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'paragraph', 'This sentence is complete.', 1, 0), + ], + [ + makeBlock('b2', 'paragraph', 'next sentence starts here', 2, 0), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + expect(stitched.pages[0]?.blocks[0]?.fragments).toHaveLength(1); + expect(stitched.pages[1]?.blocks.map((b) => b.id)).toEqual(['b2']); + }); +}); + diff --git a/tests/unit/transfer-user-documents.spec.ts b/tests/unit/transfer-user-documents.spec.ts index 22681ca..c349897 100644 --- a/tests/unit/transfer-user-documents.spec.ts +++ b/tests/unit/transfer-user-documents.spec.ts @@ -20,6 +20,8 @@ test.describe('transferUserDocuments', () => { size INTEGER NOT NULL, last_modified INTEGER NOT NULL, file_path TEXT NOT NULL, + parse_status TEXT, + parsed_json_key TEXT, created_at INTEGER, PRIMARY KEY (id, user_id) );