diff --git a/drizzle/postgres/0002_add_segment_key_to_tts_segments.sql b/drizzle/postgres/0002_add_segment_key_to_tts_segments.sql new file mode 100644 index 0000000..71768ee --- /dev/null +++ b/drizzle/postgres/0002_add_segment_key_to_tts_segments.sql @@ -0,0 +1 @@ +ALTER TABLE "tts_segments" ADD COLUMN "segment_key" text; \ No newline at end of file diff --git a/drizzle/postgres/meta/0002_snapshot.json b/drizzle/postgres/meta/0002_snapshot.json new file mode 100644 index 0000000..61d9330 --- /dev/null +++ b/drizzle/postgres/meta/0002_snapshot.json @@ -0,0 +1,1301 @@ +{ + "id": "8aed8970-1fa7-44fd-b13c-30574bbcfea8", + "prevId": "f4752b9c-f77d-45c6-91a1-ce5eb03b246b", + "version": "7", + "dialect": "postgresql", + "tables": { + "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.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 + }, + "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_segments": { + "name": "tts_segments", + "schema": "", + "columns": { + "segment_id": { + "name": "segment_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_json": { + "name": "locator_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "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 + }, + "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_segments_lookup": { + "name": "idx_tts_segments_lookup", + "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": "settings_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segments_doc_index": { + "name": "idx_tts_segments_doc_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segments_user_id_user_id_fk": { + "name": "tts_segments_user_id_user_id_fk", + "tableFrom": "tts_segments", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segments_segment_id_user_id_pk": { + "name": "tts_segments_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 + } + }, + "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 1cc5219..8d619a9 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1777928320804, "tag": "0001_tts_segments", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1778502597852, + "tag": "0002_add_segment_key_to_tts_segments", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0002_add_segment_key_to_tts_segments.sql b/drizzle/sqlite/0002_add_segment_key_to_tts_segments.sql new file mode 100644 index 0000000..f0e101a --- /dev/null +++ b/drizzle/sqlite/0002_add_segment_key_to_tts_segments.sql @@ -0,0 +1 @@ +ALTER TABLE `tts_segments` ADD `segment_key` text; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0002_snapshot.json b/drizzle/sqlite/meta/0002_snapshot.json new file mode 100644 index 0000000..7d60c70 --- /dev/null +++ b/drizzle/sqlite/meta/0002_snapshot.json @@ -0,0 +1,1257 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7587ff4b-f7d8-4e20-8ba9-1d5c90e1d3c5", + "prevId": "c7822c2d-e42e-4c2e-868d-77175178be88", + "tables": { + "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": {} + }, + "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 + }, + "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_segments": { + "name": "tts_segments", + "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 + }, + "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_json": { + "name": "locator_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "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 + }, + "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_segments_lookup": { + "name": "idx_tts_segments_lookup", + "columns": [ + "user_id", + "document_id", + "document_version", + "settings_hash" + ], + "isUnique": false + }, + "idx_tts_segments_doc_index": { + "name": "idx_tts_segments_doc_index", + "columns": [ + "user_id", + "document_id", + "segment_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segments_user_id_user_id_fk": { + "name": "tts_segments_user_id_user_id_fk", + "tableFrom": "tts_segments", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segments_segment_id_user_id_pk": { + "columns": [ + "segment_id", + "user_id" + ], + "name": "tts_segments_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 + } + }, + "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 0e82b2b..40e766c 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1777928320552, "tag": "0001_tts_segments", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1778502597591, + "tag": "0002_add_segment_key_to_tts_segments", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index f524dc0..ea647ce 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -359,6 +359,9 @@ export async function POST(request: NextRequest) { }); const segmentLocatorJson = canonicalLocatorJson(segment.locator); + const segmentKeyForRow = typeof segment.original.segmentKey === 'string' && segment.original.segmentKey.trim() + ? segment.original.segmentKey.trim() + : null; await db .insert(ttsSegments) .values({ @@ -368,6 +371,7 @@ export async function POST(request: NextRequest) { readerType: scope.readerType, documentVersion: scope.documentVersion, segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, locatorJson: segmentLocatorJson, settingsHash, settingsJson, @@ -386,6 +390,7 @@ export async function POST(request: NextRequest) { readerType: scope.readerType, documentVersion: scope.documentVersion, segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, locatorJson: segmentLocatorJson, settingsHash, settingsJson, diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts index fcaf0ca..89d2ae3 100644 --- a/src/app/api/tts/segments/manifest/route.ts +++ b/src/app/api/tts/segments/manifest/route.ts @@ -8,7 +8,7 @@ import { decodeManifestCursor, dedupeManifestVariants, encodeManifestCursor, - locatorGroupKey, + locatorIdentityKey, parseManifestPageSize, } from '@/lib/server/tts/segments-manifest'; import type { @@ -106,6 +106,7 @@ export async function GET(request: NextRequest) { readerType: string; documentVersion: number; segmentIndex: number; + segmentKey: string | null; locatorJson: string | null; settingsHash: string; settingsJson: unknown; @@ -127,18 +128,24 @@ export async function GET(request: NextRequest) { for (const row of rows) { const locator = parseLocator(row.locatorJson); - const groupKey = `${row.segmentIndex}|${locatorGroupKey(locator)}`; + // Use the per-row identity key (not the coarse sidebar group key) so two + // persisted rows in the same chapter at different `charOffset`s remain + // distinct entries instead of collapsing into one bucket whose locator + // only reflects the first row seen. + const groupKey = `${row.segmentIndex}|${locatorIdentityKey(locator)}`; let entry = grouped.get(groupKey); if (!entry) { entry = { segmentIndex: row.segmentIndex, + segmentKey: row.segmentKey, locator, variants: [], }; grouped.set(groupKey, entry); - } else if (!entry.locator) { - entry.locator = locator; + } else { + if (!entry.locator) entry.locator = locator; + if (!entry.segmentKey && row.segmentKey) entry.segmentKey = row.segmentKey; } let alignmentWordCount = 0; @@ -182,6 +189,7 @@ export async function GET(request: NextRequest) { .map(([groupKey, segment]) => ({ groupKey, segmentIndex: segment.segmentIndex, + segmentKey: segment.segmentKey, locator: segment.locator, variants: dedupeManifestVariants(segment.variants), })) @@ -206,6 +214,7 @@ export async function GET(request: NextRequest) { documentId, segments: page.map((segment) => ({ segmentIndex: segment.segmentIndex, + segmentKey: segment.segmentKey ?? null, locator: segment.locator, variants: segment.variants, })), diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 89aafb9..f7bfce5 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -7,7 +7,23 @@ import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; -import { compareSegmentLocators, locatorGroupKey, normalizeEpubLocationToken } from '@/lib/shared/tts-locator'; +import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator'; +import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan'; +import { useEPUB } from '@/contexts/EPUBContext'; +import { + canonicalizeEpubSegmentsAgainstSpineText, + type CanonicalizedEpubSegment, +} from '@/lib/client/epub/canonicalize-epub-segment'; +import { + getSpineItemPlainText, + resolveMonotonicSentenceOffsets, + resolveSpineFromCfi, +} from '@/lib/client/epub/spine-coordinates'; +import { + isHtmlLocator, + isPdfLocator, + isStableEpubLocator, +} from '@/types/client'; import type { TTSSegmentLocator, TTSSegmentRow, @@ -87,51 +103,39 @@ function statusColor(status: TTSSegmentVariant['status']): string { return 'bg-muted'; } -function locatorMatchesCurrent( - locator: TTSSegmentLocator | null, - currentLocation: string | number, - currentPageNumber: number, -): boolean { - if (!locator) return false; - if (typeof locator.location === 'string' && locator.location.length > 0) { - if (locator.readerType === 'epub') { - if (typeof currentLocation !== 'string') return false; - return normalizeEpubLocationToken(locator.location) === normalizeEpubLocationToken(currentLocation); - } - return String(locator.location) === String(currentLocation); - } - if (typeof locator.page === 'number' && Number.isFinite(locator.page)) { - return Math.floor(locator.page) === Math.floor(Number(currentPageNumber || 1)); - } - return false; -} - function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string { if (!locator) return 'Unknown location'; - const parts: string[] = []; - if (typeof locator.page === 'number' && Number.isFinite(locator.page)) { - parts.push(`Page ${Math.floor(locator.page)}`); + if (isStableEpubLocator(locator)) { + // Show the spine item filename as a recognisable chapter label. epubjs + // hrefs look like "OEBPS/Chapter_03.xhtml" — strip the directory for the + // primary label and keep the index for tie-breaks. + const base = locator.spineHref.split('/').pop() || locator.spineHref; + const stem = base.replace(/\.x?html?$/i, ''); + return `${stem} · EPUB`; } - if (typeof locator.location === 'string' && locator.location) { - parts.push(locator.location); + if (isPdfLocator(locator)) { + return `Page ${Math.floor(locator.page)} · PDF`; } - if (locator.readerType) { - parts.push(locator.readerType.toUpperCase()); + if (isHtmlLocator(locator)) { + return `${locator.location} · HTML`; } - return parts.join(' · ') || 'Unknown location'; + return 'Unknown location'; } function compareRows(a: TTSSegmentRow, b: TTSSegmentRow): number { const byLocator = compareSegmentLocators(a.locator, b.locator); if (byLocator !== 0) return byLocator; if (a.segmentIndex !== b.segmentIndex) return a.segmentIndex - b.segmentIndex; - return locatorGroupKey(a.locator).localeCompare(locatorGroupKey(b.locator)); + return locatorIdentityKey(a.locator).localeCompare(locatorIdentityKey(b.locator)); } function mergeRows(existing: TTSSegmentRow[], incoming: TTSSegmentRow[]): TTSSegmentRow[] { const map = new Map(); const upsert = (row: TTSSegmentRow) => { - const key = `${row.segmentIndex}|${locatorGroupKey(row.locator)}`; + // Identity key (NOT the coarse sidebar group key) so that two rows in the + // same chapter at different charOffsets stay as separate entries instead + // of collapsing into one. + const key = `${row.segmentIndex}|${locatorIdentityKey(row.locator)}`; const prev = map.get(key); if (!prev) { map.set(key, row); @@ -176,8 +180,60 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb currDocPageNumber, isPlaying, playFromSegment, + activeReaderType, } = useTTS(); - const { ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions, updateConfigKey } = useConfig(); + const { + ttsProvider, + ttsModel, + voice, + voiceSpeed, + ttsInstructions, + ttsSegmentMaxBlockLength, + updateConfigKey, + } = useConfig(); + const { bookRef } = useEPUB(); + + /** + * Canonicalized per-sentence identities for the currently rendered page. + * Each local sentence is mapped onto the spine-level canonical segment plan + * (forward-only ordinal walk), so overlap-boundary local splits still resolve + * to the same canonical `segmentKey`/`charOffset` as persisted rows. + */ + const [synthRowCanonical, setSynthRowCanonical] = useState>([]); + useEffect(() => { + if (typeof currDocPage !== 'string' || !currDocPage || sentences.length === 0) { + setSynthRowCanonical([]); + return; + } + const book = bookRef.current; + if (!book?.isOpen) { + setSynthRowCanonical([]); + return; + } + let cancelled = false; + void (async () => { + const spine = resolveSpineFromCfi(book, currDocPage); + if (!spine) { + if (!cancelled) setSynthRowCanonical([]); + return; + } + const spineText = await getSpineItemPlainText(book, spine.href); + if (cancelled) return; + const offsets = resolveMonotonicSentenceOffsets(spineText, sentences); + const next = canonicalizeEpubSegmentsAgainstSpineText({ + segmentTexts: sentences, + hintCharOffsets: offsets, + spineText, + spineHref: spine.href, + spineIndex: spine.index, + cfi: currDocPage, + keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + maxBlockLength: ttsSegmentMaxBlockLength, + }); + if (!cancelled) setSynthRowCanonical(next); + })(); + return () => { cancelled = true; }; + }, [bookRef, currDocPage, sentences, documentId, activeReaderType, ttsSegmentMaxBlockLength]); const [state, setState] = useState({ kind: 'idle' }); const [isClearing, setIsClearing] = useState(false); @@ -345,72 +401,140 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb }, [playFromSegment]); const rowsToRender = useMemo(() => { - if (state.kind !== 'ready') return [] as Array<{ + type Entry = { segmentIndex: number; sentenceText: string; row: TTSSegmentRow; isCurrentLocation: boolean; groupKey: string; groupLabel: string; - }>; - const currentRowsFromManifest = state.data.filter((row) => - locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber), - ); - const nonCurrentRows = state.data.filter((row) => - !locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber), - ); + /** + * Synthesized rows are produced locally from the currently rendered page's + * sentences. They carry sentence text and the live-play highlight. Manifest + * rows come from the server-side manifest and may overlap synthesized rows + * for the current page; we render them as their own listings so the + * sidebar can show the rest of the chapter (and other chapters) without + * needing to re-derive page boundaries on the client. + */ + isSynthesized: boolean; + }; + if (state.kind !== 'ready') return [] as Entry[]; - const inferredCurrentLocator = (() => { - const first = currentRowsFromManifest[0]?.locator; - if (first) return first; + // Fallback locator for the live viewport. Used when per-sentence + // canonical resolution hasn't completed yet and for PDF/HTML, which don't + // have a spine concept. + const inferredCurrentLocator: TTSSegmentLocator | null = (() => { if (typeof currDocPage === 'string' && currDocPage.length > 0) { - return { - location: currDocPage, - readerType: 'epub' as const, - }; + const book = bookRef.current; + const spine = book && book.isOpen ? resolveSpineFromCfi(book, currDocPage) : null; + if (spine) { + return { + readerType: 'epub', + spineHref: spine.href, + spineIndex: spine.index, + charOffset: 0, + cfi: currDocPage, + }; + } + return null; } if (typeof currDocPageNumber === 'number' && Number.isFinite(currDocPageNumber)) { - return { - page: Math.floor(currDocPageNumber), - readerType: 'pdf' as const, - }; + return { readerType: 'pdf', page: Math.floor(currDocPageNumber) }; } return null; })(); - const variantsByIndex = new Map(); - for (const row of currentRowsFromManifest) { - if (!variantsByIndex.has(row.segmentIndex)) { - variantsByIndex.set(row.segmentIndex, []); - } - const merged = variantsByIndex.get(row.segmentIndex)!; - const seenIds = new Set(merged.map((variant) => variant.segmentId)); - for (const variant of row.variants ?? []) { - if (seenIds.has(variant.segmentId)) continue; - seenIds.add(variant.segmentId); - merged.push(variant); + // Index manifest rows by their content-stable segmentKey so we can attach + // their variants to the matching synthesized current-page row. This + // collapses the visible duplicates (same content showing up twice — once + // as a synth row with sentence text, once as a manifest row with audio). + const manifestBySegmentKey = new Map(); + for (const row of state.data) { + if (row.segmentKey) manifestBySegmentKey.set(row.segmentKey, row); + } + + const entries: Entry[] = []; + const claimedManifestKeys = new Set(); + const keyPrefix = buildSegmentKeyPrefix(documentId, activeReaderType); + + // Synthesized rows: one per local sentence. Always tagged as + // `isCurrentLocation: true` so the live-playback highlight can fire on + // `currentSentenceIndex`. We compute each sentence's `segmentKey` the same + // way TTSContext does for persistence; when a manifest row carries the + // same key, we pull in its variants/audio but keep the locally-resolved + // per-sentence locator for sort positioning. + // + // **Locator preference (drives sort order):** + // 1. Canonicalized per-sentence locator (`synthRowCanonical[i]`) — + // identity-stable with persisted manifest rows across resize and + // boundary split variations. + // 2. Matching manifest row's persisted locator — fallback while the + // async resolution is still running. + // 3. `inferredCurrentLocator` (chapter + 0) — final fallback. + // + // Variants/audio are always taken from the manifest match when present, + // regardless of which locator wins. + if (inferredCurrentLocator) { + for (let segmentIndex = 0; segmentIndex < sentences.length; segmentIndex += 1) { + const sentence = sentences[segmentIndex] ?? ''; + const canonical = synthRowCanonical[segmentIndex] ?? null; + const segmentKey = canonical?.segmentKey + ?? (sentence ? buildSegmentKey(keyPrefix, sentence) : null); + const manifestMatch = segmentKey ? manifestBySegmentKey.get(segmentKey) : undefined; + if (segmentKey && manifestMatch) claimedManifestKeys.add(segmentKey); + + const localPerSentence = canonical?.locator ?? null; + const mergedLocator: TTSSegmentLocator = + localPerSentence + ?? manifestMatch?.locator + ?? inferredCurrentLocator; + const synthRow: TTSSegmentRow = { + segmentIndex, + segmentKey, + locator: mergedLocator, + variants: manifestMatch?.variants ?? [], + }; + entries.push({ + segmentIndex, + sentenceText: sentence, + row: synthRow, + isCurrentLocation: true, + groupKey: locatorGroupKey(mergedLocator), + groupLabel: formatLocatorGroupLabel(mergedLocator), + isSynthesized: true, + }); } } - const currentRows: TTSSegmentRow[] = sentences.map((_, segmentIndex) => ({ - segmentIndex, - locator: inferredCurrentLocator, - variants: variantsByIndex.get(segmentIndex) ?? [], - })); - - const mergedRows = [...currentRows, ...nonCurrentRows].sort(compareRows); - return mergedRows.map((row) => { - const isCurrentLocation = locatorMatchesCurrent(row.locator, currDocPage, currDocPageNumber); - return { + // Manifest rows not claimed by a synth row (i.e. content not currently on + // screen) render as their own listings. They keep their original locator + // and group, so they sort into their chapter bucket at their real + // `charOffset` position. + for (const row of state.data) { + if (row.segmentKey && claimedManifestKeys.has(row.segmentKey)) continue; + entries.push({ segmentIndex: row.segmentIndex, - sentenceText: isCurrentLocation ? (sentences[row.segmentIndex] ?? '') : '', + sentenceText: '', row, - isCurrentLocation, + isCurrentLocation: false, groupKey: locatorGroupKey(row.locator), groupLabel: formatLocatorGroupLabel(row.locator), - }; + isSynthesized: false, + }); + } + + entries.sort((a, b) => { + const byLocator = compareSegmentLocators(a.row.locator, b.row.locator); + if (byLocator !== 0) return byLocator; + // Within the same locator group, place synthesized (current-page) rows + // first so the user's active reading position floats to the top of the + // chapter group. + if (a.isSynthesized !== b.isSynthesized) return a.isSynthesized ? -1 : 1; + return a.segmentIndex - b.segmentIndex; }); - }, [state, currDocPage, currDocPageNumber, sentences]); + + return entries; + }, [state, currDocPage, currDocPageNumber, sentences, bookRef, documentId, activeReaderType, synthRowCanonical]); const totalVariants = state.kind === 'ready' ? rowsToRender.reduce((sum, r) => sum + r.row.variants.length, 0) @@ -538,7 +662,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb )} {state.kind === 'ready' && rowsToRender.length > 0 && (
    - {rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel }, rowIndex) => { + {rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel, isSynthesized }, rowIndex) => { const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null; const showGroupHeader = previousGroupKey !== groupKey; const isCurrent = isCurrentLocation && segmentIndex === currentSentenceIndex; @@ -562,7 +686,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb const playable = !!(activeVariant && activeVariant.audioPresignUrl); return (
  • @@ -700,9 +824,13 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) { {row.locator ? ( - {row.locator.page !== undefined ? `p.${row.locator.page} ` : ''} - {row.locator.location ?? ''} - {row.locator.readerType ? ` (${row.locator.readerType})` : ''} + {isStableEpubLocator(row.locator) + ? `epub spine[${row.locator.spineIndex}] ${row.locator.spineHref} @${row.locator.charOffset}` + : isPdfLocator(row.locator) + ? `pdf p.${row.locator.page}` + : isHtmlLocator(row.locator) + ? `html ${row.locator.location}` + : `${row.locator.readerType || '?'} (legacy)`} ) : ( none diff --git a/src/components/views/EPUBViewer.tsx b/src/components/views/EPUBViewer.tsx index 5e706e6..f269eab 100644 --- a/src/components/views/EPUBViewer.tsx +++ b/src/components/views/EPUBViewer.tsx @@ -38,10 +38,12 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { highlightWordIndex, clearWordHighlights, walkUpcomingRenderedLocations, + resolveEpubLocator, } = useEPUB(); const { registerLocationChangeHandler, registerEpubLocationWalker, + registerEpubLocatorResolver, pause, currentSegment, currentSentenceAlignment, @@ -74,11 +76,20 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { useEffect(() => { registerLocationChangeHandler(handleLocationChanged); registerEpubLocationWalker(walkUpcomingRenderedLocations); + registerEpubLocatorResolver(resolveEpubLocator); return () => { registerLocationChangeHandler(null); registerEpubLocationWalker(null); + registerEpubLocatorResolver(null); }; - }, [registerLocationChangeHandler, registerEpubLocationWalker, handleLocationChanged, walkUpcomingRenderedLocations]); + }, [ + registerLocationChangeHandler, + registerEpubLocationWalker, + registerEpubLocatorResolver, + handleLocationChanged, + walkUpcomingRenderedLocations, + resolveEpubLocator, + ]); // Handle highlighting useEffect(() => { diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 42d0152..88c9ae4 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -23,7 +23,9 @@ import { scheduleDocumentProgressSync } from '@/lib/client/api/user-state'; import { getDocumentMetadata } from '@/lib/client/api/documents'; import { ensureCachedDocument } from '@/lib/client/cache/documents'; import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-location-walker'; -import { useTTS } from '@/contexts/TTSContext'; +import { canonicalizeEpubSegmentAgainstSpineText } from '@/lib/client/epub/canonicalize-epub-segment'; +import { buildEpubLocator, getSpineItemPlainText } from '@/lib/client/epub/spine-coordinates'; +import { useTTS, type EpubLocatorResolver } from '@/contexts/TTSContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { createRangeCfi } from '@/lib/client/epub'; import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator'; @@ -41,7 +43,8 @@ import type { TTSAudiobookFormat, TTSAudiobookChapter, } from '@/types/tts'; -import type { AudiobookGenerationSettings } from '@/types/client'; +import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client'; +import { isStableEpubLocator } from '@/types/client'; import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; interface EPUBContextType { @@ -54,6 +57,7 @@ interface EPUBContextType { clearCurrDoc: () => void; extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise; walkUpcomingRenderedLocations: EpubRenderedLocationWalker; + resolveEpubLocator: EpubLocatorResolver; createFullAudioBook: ( onProgress: (progress: number) => void, signal?: AbortSignal, @@ -536,6 +540,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { apiKey, baseUrl, ttsProvider, + ttsSegmentMaxBlockLength, smartSentenceSplitting, epubTheme, epubHighlightEnabled, @@ -544,6 +549,11 @@ export function EPUBProvider({ children }: { children: ReactNode }) { const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); + // Mirror state into a ref so resolveEpubLocator (registered once with + // TTSContext via a stable callback) can always read the latest page text + // without forcing re-registration on every page turn. + const currDocTextRef = useRef(undefined); + useEffect(() => { currDocTextRef.current = currDocText; }, [currDocText]); const [isAudioCombining] = useState(false); // Add new refs @@ -744,6 +754,57 @@ export function EPUBProvider({ children }: { children: ReactNode }) { } }, [loadSpineSection]); + /** + * Resolves a draft EPUB locator (typically `{ readerType: 'epub', location: + * }`) into stable spine coordinates using the live `Book` instance. + * Registered with TTSContext so segment-persist payloads are normalised to + * viewport-independent coordinates before they hit the server. + * + * Returns null when there's no live book or the CFI doesn't resolve. + */ + const resolveEpubLocator = useCallback(async ( + draft, + segmentText, + options, + ) => { + const book = bookRef.current; + if (!book || !book.isOpen) return null; + const resolvedLocator = (() => { + if (isStableEpubLocator(draft)) return Promise.resolve(draft); + const cfi = (typeof draft.cfi === 'string' && draft.cfi) + || (typeof draft.location === 'string' && draft.location) + || ''; + if (!cfi) return Promise.resolve(null); + // Pass the current rendered page's text as the chunk anchor so the + // per-segment search starts at this page's position in the spine. + const chunkText = currDocTextRef.current; + return buildEpubLocator(book, cfi, segmentText, chunkText); + })(); + + const stable = await resolvedLocator; + if (!stable || !isStableEpubLocator(stable)) return null; + + const spineText = await getSpineItemPlainText(book, stable.spineHref); + const canonical = canonicalizeEpubSegmentAgainstSpineText({ + segmentText, + spineText, + spineHref: stable.spineHref, + spineIndex: stable.spineIndex, + hintCharOffset: stable.charOffset, + cfi: stable.cfi, + keyPrefix: options?.keyPrefix, + maxBlockLength: options?.maxBlockLength ?? ttsSegmentMaxBlockLength, + }); + if (!canonical) return null; + + return { + locator: canonical.locator, + segmentKey: canonical.segmentKey, + segmentIndex: canonical.segmentIndex, + text: canonical.text, + }; + }, [ttsSegmentMaxBlockLength]); + const walkUpcomingRenderedLocations = useCallback(async (startCfi, depth, signal) => { if (!startCfi || depth <= 0 || signal.aborted) return []; const visibleRendition = renditionRef.current; @@ -763,10 +824,24 @@ export function EPUBProvider({ children }: { children: ReactNode }) { const width = Math.max(320, Math.floor(bounds?.width || 900)); const height = Math.max(320, Math.floor(bounds?.height || 700)); + const visibleContents = typeof visibleRendition.getContents === 'function' + ? visibleRendition.getContents() + : []; + const visibleContent = Array.isArray(visibleContents) ? visibleContents[0] : visibleContents; + const contentDoc = (visibleContent as { document?: Document | null } | null | undefined)?.document ?? null; + const contentBody = contentDoc?.body ?? null; + const bodyStyle = contentBody ? getComputedStyle(contentBody) : null; + const theme = epubTheme ? { foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'), base: getComputedStyle(document.documentElement).getPropertyValue('--base'), + fontFamily: bodyStyle?.fontFamily || undefined, + fontSize: bodyStyle?.fontSize || undefined, + lineHeight: bodyStyle?.lineHeight || undefined, + fontWeight: bodyStyle?.fontWeight || undefined, + letterSpacing: bodyStyle?.letterSpacing || undefined, + wordSpacing: bodyStyle?.wordSpacing || undefined, } : null; @@ -1078,6 +1153,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { clearCurrDoc, extractPageText, walkUpcomingRenderedLocations, + resolveEpubLocator, createFullAudioBook, regenerateChapter, bookRef, @@ -1102,6 +1178,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { clearCurrDoc, extractPageText, walkUpcomingRenderedLocations, + resolveEpubLocator, createFullAudioBook, regenerateChapter, handleLocationChanged, diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 3c01d90..4086426 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -38,17 +38,22 @@ import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/ import { withRetry, ensureTtsSegments } from '@/lib/client/api/audiobooks'; import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; import { + buildSegmentKeyPrefix, planCanonicalTtsSegments, type CanonicalTtsSegment, type CanonicalTtsSourceUnit, } from '@/lib/shared/tts-segment-plan'; +import { + buildWalkerPlanningSourceUnits, + selectUpcomingWalkerItems, +} from '@/lib/shared/tts-epub-preload'; import { completedEpubBoundarySegment, resolveEpubBoundaryHandoffStartIndex, resolveEpubReplaySuppressionAction, type CompletedEpubBoundarySegment, } from '@/lib/shared/tts-epub-handoff'; -import { normalizeEpubLocationToken, normalizeTtsLocationKey } from '@/lib/shared/tts-locator'; +import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator'; import { isKokoroModel } from '@/lib/shared/kokoro'; import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; @@ -61,10 +66,35 @@ import type { } from '@/types/tts'; import type { TTSRequestHeaders, + TTSSegmentInput, TTSSegmentLocator, TTSRetryOptions, TTSSegmentManifestItem, } from '@/types/client'; +import { isStableEpubLocator } from '@/types/client'; + +/** + * Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub', + * location: }`) into a stable book coordinate. The resolver lives in + * EPUBContext where the live `Book` instance is available; TTSContext calls it + * just before posting segments to the server so what gets persisted is + * viewport-independent. Returns null when the CFI can't be resolved. + */ +export type EpubLocatorResolver = ( + draft: TTSSegmentLocator, + segmentText: string, + options?: { + segmentIndex: number; + segmentKey?: string | null; + keyPrefix: string; + maxBlockLength: number; + }, +) => Promise<{ + locator: TTSSegmentLocator; + segmentKey?: string | null; + segmentIndex?: number; + text?: string; +} | null>; import type { ReaderType } from '@/types/user-state'; import { clampSegmentPreloadDepth, @@ -110,8 +140,11 @@ interface TTSContextType extends TTSPlaybackState { skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void; registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation registerEpubLocationWalker: (walker: EpubRenderedLocationWalker | null) => void; + registerEpubLocatorResolver: (resolver: EpubLocatorResolver | null) => void; // EPUB-only: resolves CFI drafts to stable spine coords before persist registerVisualPageChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; setIsEPUB: (isEPUB: boolean) => void; + /** Effective reader type used to mint segmentKeys (see buildSegmentKeyPrefix). */ + activeReaderType: ReaderType; } interface SetTextOptions { @@ -214,6 +247,22 @@ const buildScopedSegmentCacheKey = ( }; const buildLocatorRequestKey = (locator: TTSSegmentLocator): string => { + // Stable EPUB locators: use spine identity + charOffset for a unique, + // viewport-independent cache key. Falling through to `locator.location` + // would yield the empty string for the new shape and collide across + // segments. + if (locator.readerType === 'epub') { + if ( + typeof locator.spineHref === 'string' + && typeof locator.spineIndex === 'number' + && typeof locator.charOffset === 'number' + ) { + return `epub:${locator.spineIndex}:${locator.spineHref}:${locator.charOffset}`; + } + if (typeof locator.cfi === 'string' && locator.cfi) { + return normalizeLocationKey(locator.cfi); + } + } if (typeof locator.location === 'string' && locator.location) { return normalizeLocationKey(locator.location); } @@ -314,6 +363,21 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement isAtLimit, } = useAuthRateLimit(); + // Get document ID and reader type from URL + const { id } = useParams(); + const pathname = usePathname(); + const documentId = useMemo(() => { + if (typeof id === 'string') return id; + if (Array.isArray(id)) return id[0]; + return ''; + }, [id]); + + const currentReaderType: ReaderType = useMemo(() => { + if (pathname.startsWith('/epub/')) return 'epub'; + if (pathname.startsWith('/html/')) return 'html'; + return 'pdf'; + }, [pathname]); + // Add ref for location change handler const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); const visualPageChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); @@ -334,6 +398,77 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement epubLocationWalkerRef.current = walker; }, []); + /** + * Resolves a CFI + segment text into stable EPUB coordinates. Registered by + * EPUBContext (which owns the live `Book` instance). Used at server-persist + * time so the saved locator carries `spineHref`/`spineIndex`/`charOffset` + * rather than the viewport-dependent CFI string. Returns null when the CFI + * can't be resolved — callers should drop the segment from the persist + * payload in that case to avoid writing legacy-shape locators. + */ + const epubLocatorResolverRef = useRef(null); + + const registerEpubLocatorResolver = useCallback((resolver: EpubLocatorResolver | null) => { + epubLocatorResolverRef.current = resolver; + }, []); + + /** + * Walks the segment payload that's about to be POSTed to + * /api/tts/segments/ensure and canonicalizes EPUB entries through the + * registered resolver. This normalizes both draft and already-stable locators + * to a spine-level canonical segment identity (text/key/index/locator). + * Non-EPUB locators are untouched. + * + * Segments whose EPUB locators can't be resolved are DROPPED from the + * payload — we'd rather persist nothing than persist a viewport-dependent + * locator that will misbehave across devices and resizes. + */ + const resolveSegmentsForPersist = useCallback(async ( + segments: TTSSegmentInput[], + ): Promise => { + const resolver = epubLocatorResolverRef.current; + const out: TTSSegmentInput[] = []; + for (const segment of segments) { + const locator = segment.locator; + if (!locator || locator.readerType !== 'epub') { + out.push(segment); + continue; + } + if (!resolver) { + // No book available to resolve — drop. This can happen during early + // boot before EPUBContext has mounted. + continue; + } + const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub'); + try { + const resolved = await resolver(locator, segment.text, { + segmentIndex: segment.segmentIndex, + segmentKey: segment.segmentKey ?? null, + keyPrefix, + maxBlockLength: ttsSegmentMaxBlockLength, + }); + if (resolved && isStableEpubLocator(resolved.locator)) { + out.push({ + ...segment, + locator: resolved.locator, + ...(typeof resolved.segmentKey === 'string' && resolved.segmentKey.trim() + ? { segmentKey: resolved.segmentKey.trim() } + : {}), + ...(typeof resolved.segmentIndex === 'number' && Number.isFinite(resolved.segmentIndex) + ? { segmentIndex: Math.max(0, Math.floor(resolved.segmentIndex)) } + : {}), + ...(typeof resolved.text === 'string' && resolved.text.trim() + ? { text: resolved.text } + : {}), + }); + } + } catch (error) { + console.warn('Failed to resolve EPUB locator; dropping segment', error); + } + } + return out; + }, [documentId, ttsSegmentMaxBlockLength]); + /** * Registers a handler function for visual page changes in EPUB documents * This is only used for EPUB documents to handle visual page navigation @@ -344,21 +479,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement visualPageChangeHandlerRef.current = handler; }, []); - // Get document ID from URL params - const { id } = useParams(); - const pathname = usePathname(); - const documentId = useMemo(() => { - if (typeof id === 'string') return id; - if (Array.isArray(id)) return id[0]; - return ''; - }, [id]); - - const currentReaderType: ReaderType = useMemo(() => { - if (pathname.startsWith('/epub/')) return 'epub'; - if (pathname.startsWith('/html/')) return 'html'; - return 'pdf'; - }, [pathname]); - /** * State Management */ @@ -366,6 +486,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const [isEPUB, setIsEPUB] = useState(false); const [isProcessing, setIsProcessing] = useState(false); + /** + * Resolved reader type for segment planning. Mirrors the `activeReaderType` + * used inside `setText`/preload paths so external consumers (e.g. the + * sidebar) can compute identical `segmentKey`s from local text and match + * them against persisted manifest rows by content identity. + */ + const activeReaderType: ReaderType = useMemo( + () => (isEPUB ? 'epub' : currentReaderType), + [isEPUB, currentReaderType], + ); + const [currDocPage, setCurrDocPage] = useState(1); const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only const [currDocPages, setCurrDocPages] = useState(); @@ -422,6 +553,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const currentIndexRef = useRef(0); const plannedSegmentsByLocationRef = useRef>(new Map()); const currentSourceUnitRef = useRef(null); + const currentSourceContextUnitsRef = useRef([]); const completedEpubBoundarySegmentRef = useRef(null); const pendingNextLocationRef = useRef(undefined); @@ -527,6 +659,21 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, []); + const cacheCompletedManifestForCandidate = useCallback(( + cacheKey: string, + segment: TTSSegmentManifestItem, + alignmentEnabledForCurrentDoc: boolean, + ): boolean => { + if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) { + return false; + } + setSegmentManifestCache(cacheKey, segment); + if (alignmentEnabledForCurrentDoc && segment.alignment) { + sentenceAlignmentCacheRef.current.set(cacheKey, segment.alignment); + } + return true; + }, [setSegmentManifestCache]); + const invalidatePlaybackRun = useCallback(() => { playbackRunIdRef.current += 1; playbackInFlightRef.current = false; @@ -841,7 +988,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ? normalizedOptions.location : currDocPage; const resolvedLocationKey = normalizeLocationKey(resolvedLocation); - const activeReaderType = isEPUB ? 'epub' : currentReaderType; + + // 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 + // this content at this location" — the navigation flow (handleLocationChanged → + // skipToLocation) already set it before calling setText, so this is a no-op + // for next/prev/jump. The path that needs it is **resize**: EPUBViewer's + // checkResize calls extractPageText directly (bypassing skipToLocation), so + // without this, currDocPage would stay pinned to the pre-resize CFI even + // though the page has repaginated to a new start CFI. + if (normalizedOptions.location !== undefined && normalizedOptions.location !== currDocPage) { + setCurrDocPage(normalizedOptions.location); + } const currentSourceKey = sourceKeyForLocation(resolvedLocation, currDocPage); const currentSource: CanonicalTtsSourceUnit = { sourceKey: currentSourceKey, @@ -849,10 +1007,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement locator: locatorForLocation(resolvedLocation, activeReaderType), }; - const sourceUnits: CanonicalTtsSourceUnit[] = []; + const contextSourceUnits: CanonicalTtsSourceUnit[] = []; if (smartSentenceSplitting && normalizedOptions.previousText?.trim()) { const previousLocation = normalizedOptions.previousLocation; - sourceUnits.push({ + contextSourceUnits.push({ sourceKey: previousLocation !== undefined ? sourceKeyForLocation(previousLocation, currDocPage) : `previous:${currentSourceKey}`, @@ -862,7 +1020,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement : null, }); } - sourceUnits.push(currentSource); + contextSourceUnits.push(currentSource); + const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits]; plannedSegmentsByLocationRef.current.clear(); pendingNextLocationRef.current = normalizedOptions.nextLocation; @@ -895,14 +1054,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const plan = planCanonicalTtsSegments(sourceUnits, { readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: `${documentId || 'document'}:${activeReaderType}:v1`, + keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), }); const currentSegments = smartSentenceSplitting ? plan.segments.filter((segment) => segment.ownerSourceKey === currentSourceKey) : planCanonicalTtsSegments([currentSource], { readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: `${documentId || 'document'}:${activeReaderType}:v1`, + keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), }).segments; const newSentences = currentSegments.map((segment) => segment.text); @@ -912,7 +1071,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement : planCanonicalTtsSegments([item], { readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: `${documentId || 'document'}:${activeReaderType}:v1`, + keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), }).segments; if (planned.length > 0) { plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned); @@ -920,6 +1079,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } currentSourceUnitRef.current = currentSource; + currentSourceContextUnitsRef.current = contextSourceUnits; if (handleBlankSection(newSentences.join(' '))) return; @@ -1038,7 +1198,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement handleBlankSection, abortAudio, isEPUB, - currentReaderType, + activeReaderType, smartSentenceSplitting, invalidatePlaybackRun, currDocPage, @@ -1272,6 +1432,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement try { onTTSStart(); + const persistSegments = await resolveSegmentsForPersist([ + { + segmentIndex: sentenceIndex, + ...(segmentKey ? { segmentKey } : {}), + text: sentence, + locator, + }, + ]); + if (persistSegments.length === 0) { + if (!preload) setIsPlaying(false); + return undefined; + } const ensured = await withRetry( async () => ensureTtsSegments({ documentId, @@ -1282,14 +1454,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement nativeSpeed: effectiveNativeSpeed, ...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}), }, - segments: [ - { - segmentIndex: sentenceIndex, - ...(segmentKey ? { segmentKey } : {}), - text: sentence, - locator, - }, - ], + segments: persistSegments, }, reqHeaders, controller.signal), retryOptions, ); @@ -1383,11 +1548,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement currentReaderType, setSegmentManifestCache, isAbortLikeError, + resolveSegmentsForPersist, ]); /** * Processes and plays the current sentence - * + * * @param {string} sentence - The sentence to process * @param {boolean} [preload=false] - Whether this is a preload request * @returns {Promise} Prepared playback source metadata @@ -2031,39 +2197,55 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (generationAtStart !== epubPreloadGenerationRef.current) return; if (!locationItems.length) return; - const currentToken = normalizeEpubLocationToken(String(currDocPage)); - const filteredLocationItems = locationItems.filter((item) => - normalizeEpubLocationToken(item.location) !== currentToken, + const upcomingLocationItems = selectUpcomingWalkerItems( + locationItems, + String(currDocPage), + maxDepth, ); - const targetDepth = Math.max(0, maxDepth - 1); - const upcomingLocationItems = filteredLocationItems.slice(0, targetDepth); if (!upcomingLocationItems.length) return; - const sourceUnits: CanonicalTtsSourceUnit[] = []; - if (smartSentenceSplitting && currentSourceUnitRef.current) { - sourceUnits.push(currentSourceUnitRef.current); - } - sourceUnits.push(...upcomingLocationItems.map((item) => ({ - sourceKey: sourceKeyForLocation(item.location, currDocPage), + // Build a stable EPUB locator for each rendered chunk from the + // walker's spine coordinates. These are viewport-independent — the + // same content yields the same locator across devices and resizes. + const locatorForWalkerItem = ( + item: typeof upcomingLocationItems[number], + ): TTSSegmentLocator => ({ + readerType: 'epub', + spineHref: item.spineHref, + spineIndex: item.spineIndex, + charOffset: item.chunkOffset, + cfi: item.cfi, + }); + + const upcomingUnits: CanonicalTtsSourceUnit[] = upcomingLocationItems.map((item) => ({ + sourceKey: sourceKeyForLocation(item.cfi, currDocPage), text: item.text, - locator: { location: item.location, readerType: 'epub' as const }, - }))); + locator: locatorForWalkerItem(item), + })); + const liveContextUnits = currentSourceContextUnitsRef.current.length > 0 + ? currentSourceContextUnitsRef.current + : (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []); + const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits( + smartSentenceSplitting, + liveContextUnits, + upcomingUnits, + ); const plan = planCanonicalTtsSegments(sourceUnits, { readerType: 'epub', maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: `${documentId || 'document'}:epub:v1`, + keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'), }); - const uniqueCandidates: EpubLocationPreloadCandidate[] = []; + const uniqueCandidates: Array = []; const seenCandidates = new Set(); for (const item of upcomingLocationItems) { - const sourceKey = sourceKeyForLocation(item.location, currDocPage); + const sourceKey = sourceKeyForLocation(item.cfi, currDocPage); const planned = plan.segments .filter((segment) => segment.ownerSourceKey === sourceKey) .slice(0, sentenceLookahead); for (let index = 0; index < planned.length; index += 1) { const segment = planned[index]; - const locator = segment.ownerLocator ?? { location: item.location, readerType: 'epub' as const }; + const locator = segment.ownerLocator ?? locatorForWalkerItem(item); const requestKey = buildSegmentRequestKey(locator, index, segment.text, segment.key); const cacheKey = buildScopedSegmentCacheKey( locator, @@ -2083,9 +2265,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement sentence: segment.text, segmentKey: segment.key, segmentIndex: index, - location: item.location, + location: item.cfi, requestKey, cacheKey, + locator, }); } } @@ -2095,12 +2278,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement segmentIndex: candidate.segmentIndex, segmentKey: candidate.segmentKey, text: candidate.sentence, - locator: { location: candidate.location, readerType: 'epub' as const }, + locator: candidate.locator, })); const preloadPromise = (async (): Promise => { onTTSStart(); started = true; + const persistPayload = await resolveSegmentsForPersist(payload); + if (persistPayload.length === 0) return; const ensured = await withRetry( async () => ensureTtsSegments({ documentId, @@ -2111,7 +2296,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement nativeSpeed: effectiveNativeSpeed, ...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}), }, - segments: payload, + segments: persistPayload, }, reqHeaders, controller.signal), retryOptions, ); @@ -2128,10 +2313,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement for (const candidate of uniqueCandidates) { const segment = segmentLookup.get(candidate.segmentKey); if (!segment) continue; - setSegmentManifestCache(candidate.cacheKey, segment); - if (alignmentEnabledForCurrentDoc && segment.alignment) { - sentenceAlignmentCacheRef.current.set(candidate.cacheKey, segment.alignment); - } + cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc); } })(); @@ -2280,9 +2462,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement text: candidate.sentence, locator: candidate.locator, })); + const candidateLookup = new Map(); + const candidateLookupKey = (segmentIndex: number, segmentKey?: string | null) => + `${segmentIndex}|${segmentKey || ''}`; + for (const candidate of uniqueCandidates) { + candidateLookup.set(candidateLookupKey(candidate.segmentIndex, candidate.segmentKey), candidate); + } const preloadPromise = (async (): Promise => { try { onTTSStart(); + const persistPayload = await resolveSegmentsForPersist(payload); + if (persistPayload.length === 0) return; const ensured = await withRetry( async () => ensureTtsSegments({ documentId, @@ -2293,19 +2483,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement nativeSpeed: effectiveNativeSpeed, ...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}), }, - segments: payload, + segments: persistPayload, }, reqHeaders, controller.signal), retryOptions, ); - ensured.segments.forEach((segment, index) => { - const candidate = uniqueCandidates[index]; + ensured.segments.forEach((segment) => { + const candidate = candidateLookup.get( + candidateLookupKey(segment.segmentIndex, segment.segmentKey), + ); if (!candidate) return; - if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) return; - setSegmentManifestCache(candidate.cacheKey, segment); - if (alignmentEnabledForCurrentDoc && segment.alignment) { - sentenceAlignmentCacheRef.current.set(candidate.cacheKey, segment.alignment); - } + cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc); }); } finally { activeAbortControllers.current.delete(controller); @@ -2380,12 +2568,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement onTTSStart, onTTSComplete, processSentence, - setSegmentManifestCache, + cacheCompletedManifestForCandidate, isAbortLikeError, pdfHighlightEnabled, pdfWordHighlightEnabled, epubHighlightEnabled, epubWordHighlightEnabled, + resolveSegmentsForPersist, ]); /** @@ -2440,6 +2629,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement bumpEpubPreloadGeneration(); plannedSegmentsByLocationRef.current.clear(); currentSourceUnitRef.current = null; + currentSourceContextUnitsRef.current = []; completedEpubBoundarySegmentRef.current = null; pageFirstBlockFingerprintRef.current.clear(); setIsPlaying(false); @@ -2478,6 +2668,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const resolvedLocation: TTSLocation | undefined = (() => { if (!locator) return undefined; + // Stable EPUB locators carry the jump-hint CFI in `cfi`, not `location` + // (which is now reserved for HTML / legacy rows). + if (locator.readerType === 'epub' && typeof locator.cfi === 'string' && locator.cfi) { + return locator.cfi; + } if (typeof locator.location === 'string' && locator.location) return locator.location; if (typeof locator.page === 'number' && Number.isFinite(locator.page)) return Math.floor(locator.page); return undefined; @@ -2664,8 +2859,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement skipToLocation, registerLocationChangeHandler, registerEpubLocationWalker, + registerEpubLocatorResolver, registerVisualPageChangeHandler, - setIsEPUB + setIsEPUB, + activeReaderType, }), [ isPlaying, isProcessing, @@ -2692,10 +2889,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement skipToLocation, registerLocationChangeHandler, registerEpubLocationWalker, + registerEpubLocatorResolver, registerVisualPageChangeHandler, setIsEPUB, currentSentenceAlignment, - currentWordIndex + currentWordIndex, + activeReaderType, ]); // Use media session hook diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 685e49a..c07a7ae 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -116,6 +116,7 @@ export const ttsSegments = pgTable('tts_segments', { readerType: text('reader_type').notNull(), documentVersion: bigint('document_version', { mode: 'number' }).notNull(), segmentIndex: integer('segment_index').notNull(), + segmentKey: text('segment_key'), locatorJson: text('locator_json'), settingsHash: text('settings_hash').notNull(), settingsJson: jsonb('settings_json').notNull(), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 57b65fd..edb56e8 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -116,6 +116,7 @@ export const ttsSegments = sqliteTable('tts_segments', { readerType: text('reader_type').notNull(), documentVersion: integer('document_version').notNull(), segmentIndex: integer('segment_index').notNull(), + segmentKey: text('segment_key'), locatorJson: text('locator_json'), settingsHash: text('settings_hash').notNull(), settingsJson: text('settings_json').notNull(), diff --git a/src/lib/client/epub/canonicalize-epub-segment.ts b/src/lib/client/epub/canonicalize-epub-segment.ts new file mode 100644 index 0000000..78dbef2 --- /dev/null +++ b/src/lib/client/epub/canonicalize-epub-segment.ts @@ -0,0 +1,184 @@ +import { + buildSegmentKeyPrefix, + normalizeSegmentIdentityText, + planCanonicalTtsSegments, + type CanonicalTtsSegment, +} from '@/lib/shared/tts-segment-plan'; +import type { TTSSegmentLocator } from '@/types/client'; + +export interface CanonicalizeEpubSegmentInput { + segmentText: string; + spineText: string; + spineHref: string; + spineIndex: number; + hintCharOffset?: number; + cfi?: string; + keyPrefix?: string; + maxBlockLength?: number; +} + +export interface CanonicalizedEpubSegment { + text: string; + segmentKey: string; + segmentIndex: number; + locator: TTSSegmentLocator; +} + +export interface CanonicalizeEpubSegmentsInput extends Omit { + segmentTexts: readonly string[]; + hintCharOffsets?: readonly number[]; +} + +function distanceToHint(segment: CanonicalTtsSegment, hint: number): number { + return Math.abs(segment.startAnchor.offset - hint); +} + +function chooseClosestByHint( + segments: CanonicalTtsSegment[], + hint: number, +): CanonicalTtsSegment | null { + if (segments.length === 0) return null; + return segments + .slice() + .sort((a, b) => { + const byDistance = distanceToHint(a, hint) - distanceToHint(b, hint); + if (byDistance !== 0) return byDistance; + return a.ordinal - b.ordinal; + })[0] ?? null; +} + +function chooseByHintWindow( + segments: CanonicalTtsSegment[], + hint: number, +): CanonicalTtsSegment | null { + if (segments.length === 0) return null; + + const containing = segments.filter((segment) => + hint >= segment.startAnchor.offset && hint < segment.endAnchor.offset, + ); + if (containing.length > 0) return chooseClosestByHint(containing, hint); + + const forward = segments.filter((segment) => segment.startAnchor.offset >= hint); + if (forward.length > 0) { + return forward + .slice() + .sort((a, b) => { + const byStart = a.startAnchor.offset - b.startAnchor.offset; + if (byStart !== 0) return byStart; + return a.ordinal - b.ordinal; + })[0] ?? null; + } + + // Fallback: everything is before the hint; choose nearest by distance. + return chooseClosestByHint(segments, hint); +} + +function buildCanonicalPlan(input: Omit): CanonicalTtsSegment[] { + if (!input.spineText.trim() || !input.spineHref.trim()) { + return []; + } + + const sourceKey = `spine:${input.spineIndex}:${input.spineHref}`; + const keyPrefix = input.keyPrefix ?? buildSegmentKeyPrefix('document', 'epub'); + const plan = planCanonicalTtsSegments( + [{ + sourceKey, + text: input.spineText, + locator: { + readerType: 'epub', + spineHref: input.spineHref, + spineIndex: input.spineIndex, + charOffset: 0, + }, + }], + { + readerType: 'epub', + maxBlockLength: input.maxBlockLength, + keyPrefix, + }, + ); + return plan.segments; +} + +function toCanonicalized( + chosen: CanonicalTtsSegment, + input: Omit, +): CanonicalizedEpubSegment { + const locator: TTSSegmentLocator = { + readerType: 'epub', + spineHref: input.spineHref, + spineIndex: input.spineIndex, + charOffset: Math.max(0, chosen.startAnchor.offset), + }; + if (input.cfi) locator.cfi = input.cfi; + + return { + text: chosen.text, + segmentKey: chosen.key, + segmentIndex: chosen.ordinal, + locator, + }; +} + +/** + * Canonicalize a possibly viewport-shaped EPUB segment candidate against one + * spine item's full text. Prefers exact normalized-text matches near the hint + * offset; falls back to hint-window selection when boundaries differ. + */ +export function canonicalizeEpubSegmentAgainstSpineText( + input: CanonicalizeEpubSegmentInput, +): CanonicalizedEpubSegment | null { + if (!input.segmentText.trim()) { + return null; + } + const segments = buildCanonicalPlan(input); + if (segments.length === 0) return null; + + const normalizedCandidate = normalizeSegmentIdentityText(input.segmentText); + const hint = Math.max(0, Math.floor(input.hintCharOffset ?? 0)); + const exactMatches = segments.filter((segment) => + normalizeSegmentIdentityText(segment.text) === normalizedCandidate, + ); + + const chosen = chooseClosestByHint(exactMatches, hint) + ?? chooseByHintWindow(segments, hint); + if (!chosen) return null; + + return toCanonicalized(chosen, input); +} + +/** + * Canonicalize a sentence list against one spine item's full text with a + * monotonic (forward-only) segment ordinal cursor. This prevents overlap + * boundary rows from snapping backward to an earlier canonical segment when a + * local boundary split changes sentence text. + */ +export function canonicalizeEpubSegmentsAgainstSpineText( + input: CanonicalizeEpubSegmentsInput, +): Array { + const output: Array = input.segmentTexts.map(() => null); + const segments = buildCanonicalPlan(input); + if (segments.length === 0) return output; + + let nextMinOrdinal = 0; + for (let i = 0; i < input.segmentTexts.length; i += 1) { + const rawText = input.segmentTexts[i] ?? ''; + if (!rawText.trim()) continue; + const hint = Math.max(0, Math.floor(input.hintCharOffsets?.[i] ?? 0)); + const allowed = segments.filter((segment) => segment.ordinal >= nextMinOrdinal); + if (allowed.length === 0) break; + + const normalizedCandidate = normalizeSegmentIdentityText(rawText); + const exactMatches = allowed.filter((segment) => + normalizeSegmentIdentityText(segment.text) === normalizedCandidate, + ); + const chosen = chooseClosestByHint(exactMatches, hint) + ?? chooseByHintWindow(allowed, hint); + if (!chosen) continue; + + output[i] = toCanonicalized(chosen, input); + nextMinOrdinal = chosen.ordinal + 1; + } + + return output; +} diff --git a/src/lib/client/epub/rendered-location-walker.ts b/src/lib/client/epub/rendered-location-walker.ts index a738443..9dbf024 100644 --- a/src/lib/client/epub/rendered-location-walker.ts +++ b/src/lib/client/epub/rendered-location-walker.ts @@ -1,10 +1,27 @@ import type { Book, Rendition } from 'epubjs'; import { createRangeCfi } from '@/lib/client/epub'; +import { + buildEpubChunkAnchor, + invalidateSpinePlainTextCache, +} from '@/lib/client/epub/spine-coordinates'; +import { buildWalkerThemeRules, type WalkerThemeSnapshot } from '@/lib/client/epub/walker-theme'; export interface RenderedLocationWalkItem { - location: string; + /** Page-start CFI from the rendition. Retained as a soft jump hint only. */ + cfi: string; + /** Plain-text content of the rendered page chunk. */ text: string; + /** Spine item href the chunk belongs to. Stable across viewports. */ + spineHref: string; + /** Ordinal of the spine item within the book. Stable across viewports. */ + spineIndex: number; + /** + * Offset (in normalized character space — see normalizeSegmentIdentityText) + * where this chunk begins inside the spine item's plain text. Stable across + * viewports, so segments can be anchored to this base. + */ + chunkOffset: number; } export interface RenderedLocationWalkRequest { @@ -15,10 +32,7 @@ export interface RenderedLocationWalkRequest { width: number; height: number; spread?: string; - theme?: { - foreground: string; - base: string; - } | null; + theme?: WalkerThemeSnapshot | null; } type Session = { @@ -136,6 +150,9 @@ export class EpubRenderedLocationCloneManager { const current = this.activeSession; this.activeSession = null; if (!current) return; + try { + invalidateSpinePlainTextCache(current.book); + } catch {} try { current.rendition.destroy(); } catch {} @@ -191,12 +208,7 @@ export class EpubRenderedLocationCloneManager { if (request.theme) { try { - rendition.themes.registerRules('openreader-preload-theme', { - body: { - color: request.theme.foreground, - 'background-color': request.theme.base, - }, - }); + rendition.themes.registerRules('openreader-preload-theme', buildWalkerThemeRules(request.theme)); rendition.themes.select('openreader-preload-theme'); } catch (error) { console.warn('Failed applying preload EPUB theme rules:', error); @@ -261,9 +273,18 @@ export class EpubRenderedLocationCloneManager { const text = range?.toString()?.trim() || ''; if (!text) continue; + const chunkAnchor = await Promise.race([ + buildEpubChunkAnchor(session.book, start, text), + abortPromise, + ]); + if (!chunkAnchor) continue; + results.push({ - location: start, + cfi: start, text, + spineHref: chunkAnchor.spineHref, + spineIndex: chunkAnchor.spineIndex, + chunkOffset: chunkAnchor.charOffset, }); } diff --git a/src/lib/client/epub/spine-coordinates.ts b/src/lib/client/epub/spine-coordinates.ts new file mode 100644 index 0000000..9c458c3 --- /dev/null +++ b/src/lib/client/epub/spine-coordinates.ts @@ -0,0 +1,283 @@ +import type { Book } from 'epubjs'; +import type Section from 'epubjs/types/section'; + +import { normalizeSegmentIdentityText } from '@/lib/shared/tts-segment-plan'; +import type { TTSSegmentLocator } from '@/types/client'; + +/** + * Stable book coordinates for an EPUB anchor (a page chunk or a sentence). + * + * `charOffset` is measured in the *normalized* spine-item text — i.e. the same + * normalization used by segmentKey (see normalizeSegmentIdentityText). This is + * deliberate: we want a coordinate that is stable across viewports and that + * matches the identity space audio segments already live in. The coordinate is + * NOT intended to round-trip back to a CFI; the optional `cfi` field on the + * locator carries that jump hint separately. + */ +export interface EpubSpineCoord { + spineHref: string; + spineIndex: number; + charOffset: number; +} + +interface SectionLike { + index: number; + href?: string; + load?: ((request?: unknown) => Promise | Document) | undefined; + unload?: () => void; +} + +const PLAIN_TEXT_CACHE = new WeakMap>(); + +const getCacheBucket = (book: Book): Map => { + let bucket = PLAIN_TEXT_CACHE.get(book); + if (!bucket) { + bucket = new Map(); + PLAIN_TEXT_CACHE.set(book, bucket); + } + return bucket; +}; + +/** + * Reset the cached plain-text-by-href map for a book. Call when the underlying + * EPUB resource is destroyed/re-opened so we don't hand out text from an old + * book instance. + */ +export function invalidateSpinePlainTextCache(book: Book): void { + PLAIN_TEXT_CACHE.delete(book); +} + +/** + * Resolve a CFI (or href, or spine index) to its spine item identity. + * Returns null when the CFI can't be resolved by epub.js. + */ +export function resolveSpineFromCfi( + book: Book, + cfiOrHrefOrIndex: string | number | undefined | null, +): { href: string; index: number } | null { + if (cfiOrHrefOrIndex === null || cfiOrHrefOrIndex === undefined) return null; + try { + const section = book.spine.get(cfiOrHrefOrIndex as never) as Section | undefined; + if (!section) return null; + const href = section.href; + const index = section.index; + if (typeof href !== 'string' || !href || typeof index !== 'number' || !Number.isFinite(index)) { + return null; + } + return { href, index }; + } catch { + return null; + } +} + +/** + * Load the spine item identified by href and return its plain text content. + * Results are memoised per Book instance so this is safe to call repeatedly. + * + * Returns the empty string on any failure — callers should treat that as + * "couldn't resolve coordinates" and fall back. + */ +export async function getSpineItemPlainText(book: Book, href: string): Promise { + if (!href) return ''; + const cache = getCacheBucket(book); + const hit = cache.get(href); + if (hit !== undefined) return hit; + + try { + const section = book.spine.get(href) as Section | undefined; + if (!section || typeof section.load !== 'function') { + cache.set(href, ''); + return ''; + } + // epub.js Section.load accepts a request function; book.load is the canonical resolver. + // + // IMPORTANT: `section.load()` resolves to the spine item's `` + // Element (NOT a Document). See epubjs/src/section.js — `this.contents = + // xml.documentElement; loading.resolve(this.contents);`. So neither + // `.body` nor `.documentElement` exists on the resolved value; pulling + // text via those paths silently returns undefined and we end up with an + // empty spine string. We have to query for `` inside the element + // (or fall back to the element's own textContent if there's no body). + const loaded = await Promise.resolve( + (section as unknown as SectionLike).load!(book.load.bind(book)), + ); + const root = loaded as Element | Document | null | undefined; + let raw = ''; + if (root) { + // Handle both shapes — most epubjs versions resolve to an Element, but + // be defensive in case a future version returns a Document. + if ('body' in (root as Document) && (root as Document).body) { + raw = (root as Document).body.textContent ?? ''; + } else if (typeof (root as Element).querySelector === 'function') { + const body = (root as Element).querySelector('body'); + raw = body?.textContent ?? (root as Element).textContent ?? ''; + } else if ('textContent' in (root as Element)) { + raw = (root as Element).textContent ?? ''; + } + } + const text = typeof raw === 'string' ? raw : ''; + cache.set(href, text); + try { + (section as unknown as SectionLike).unload?.(); + } catch { + /* no-op */ + } + return text; + } catch { + cache.set(href, ''); + return ''; + } +} + +/** + * Find the character offset (in normalized space) of `segmentText` inside + * `spineText`. The optional `hintNormalized` narrows the search to start at or + * after that normalized offset, which disambiguates repeated phrases. + * + * Falls back to a from-start search when the forward search misses — useful + * for single-shot resolution where the hint may be inaccurate (e.g. a walker + * reporting a chunk start that doesn't quite align). **Do NOT use this in a + * monotonic per-sentence walk** — the from-start fallback can return an + * occurrence *before* the cursor, which silently reorders rows. Use + * `resolveMonotonicSentenceOffsets` for that case. + * + * Returns -1 if the segment text is not found. + */ +export function findSegmentOffset( + spineText: string, + segmentText: string, + hintNormalized: number = 0, +): number { + const haystack = normalizeSegmentIdentityText(spineText); + const needle = normalizeSegmentIdentityText(segmentText); + if (!haystack || !needle) return -1; + const startFrom = Math.max(0, Math.min(haystack.length, Math.floor(hintNormalized))); + const direct = haystack.indexOf(needle, startFrom); + if (direct !== -1) return direct; + // Fall back to a search from the beginning — the hint can be wrong if the + // walker reported a chunk start that doesn't align with our segment. + if (startFrom > 0) { + const fromStart = haystack.indexOf(needle); + if (fromStart !== -1) return fromStart; + } + return -1; +} + +/** + * Resolve per-sentence character offsets within a spine item's plain text, in + * document order. Searches forward only — each sentence's offset is found at + * or after the previous match's end+1. If a sentence's text isn't found ahead + * of the cursor (e.g. it contains a phrase that only recurs earlier in the + * chapter), the current cursor value is reused so the result stays + * **monotonically non-decreasing**. + * + * This guarantee is the whole point: it prevents the sidebar's sort from + * pulling a later-on-the-page sentence backwards because a substring of it + * happens to appear in a chapter heading or earlier passage. Using + * `findSegmentOffset` in a loop would let that happen via its from-start + * fallback — which is correct for single-shot lookups and wrong here. + * + * Returns an array of offsets, one per input sentence (empty/falsy sentences + * get the current cursor value). All values are >= 0 and the sequence is + * monotonic non-decreasing. + */ +export function resolveMonotonicSentenceOffsets( + spineText: string, + sentences: readonly string[], +): number[] { + const haystack = normalizeSegmentIdentityText(spineText); + const offsets: number[] = []; + let cursor = 0; + for (const sentence of sentences) { + if (!sentence) { + offsets.push(cursor); + continue; + } + const needle = normalizeSegmentIdentityText(sentence); + const found = needle && haystack ? haystack.indexOf(needle, cursor) : -1; + if (found >= 0) { + offsets.push(found); + cursor = found + 1; + } else { + offsets.push(cursor); + } + } + return offsets; +} + +/** + * Resolve a chunk-level anchor (e.g. for a rendered page or for the current + * viewport's start CFI). Returns the spine identity plus the normalized + * character offset where this chunk begins in the spine item's text. + * + * When the chunk text can't be located inside the spine item we still return + * the spine identity with `chunkOffset = 0` so callers have *something* stable + * to attach to. + */ +export async function buildEpubChunkAnchor( + book: Book, + chunkCfi: string, + chunkText: string, +): Promise<(EpubSpineCoord & { spineText: string }) | null> { + const spine = resolveSpineFromCfi(book, chunkCfi); + if (!spine) return null; + const spineText = await getSpineItemPlainText(book, spine.href); + const chunkOffset = chunkText + ? Math.max(0, findSegmentOffset(spineText, chunkText, 0)) + : 0; + return { + spineHref: spine.href, + spineIndex: spine.index, + charOffset: chunkOffset, + spineText, + }; +} + +/** + * Build the EPUB locator for a single segment, given an already-resolved chunk + * anchor. Reuses the cached spine plain text via the anchor. + * + * `cfi` is recorded as a soft jump hint only and is intentionally NOT part of + * the locator's identity. + */ +export function buildEpubLocatorFromChunk( + anchor: EpubSpineCoord & { spineText: string }, + segmentText: string, + cfi?: string, +): TTSSegmentLocator { + // Forward-only search from the chunk anchor. Critically, we do NOT use + // `findSegmentOffset` here because its from-start fallback would let a + // segment's offset jump *before* the chunk anchor — which means a + // sentence from a later page that happens to contain text also present + // earlier in the chapter (chapter heading, refrain, common phrase) would + // be persisted with an offset somewhere in an earlier page, causing it + // to interleave out-of-order in the sidebar's sort. + const haystack = normalizeSegmentIdentityText(anchor.spineText); + const needle = normalizeSegmentIdentityText(segmentText); + const found = needle && haystack ? haystack.indexOf(needle, anchor.charOffset) : -1; + const charOffset = found >= 0 ? found : anchor.charOffset; + const locator: TTSSegmentLocator = { + readerType: 'epub', + spineHref: anchor.spineHref, + spineIndex: anchor.spineIndex, + charOffset, + }; + if (cfi) locator.cfi = cfi; + return locator; +} + +/** + * One-shot helper: resolve a single segment's locator without an anchor in + * hand. Loads spine text on demand (cached). Returns null when the CFI doesn't + * resolve to a spine item. + */ +export async function buildEpubLocator( + book: Book, + chunkCfi: string, + segmentText: string, + chunkText?: string, +): Promise { + const anchor = await buildEpubChunkAnchor(book, chunkCfi, chunkText ?? segmentText); + if (!anchor) return null; + return buildEpubLocatorFromChunk(anchor, segmentText, chunkCfi); +} diff --git a/src/lib/client/epub/walker-theme.ts b/src/lib/client/epub/walker-theme.ts new file mode 100644 index 0000000..b9645d8 --- /dev/null +++ b/src/lib/client/epub/walker-theme.ts @@ -0,0 +1,30 @@ +export interface WalkerThemeSnapshot { + foreground: string; + base: string; + fontFamily?: string; + fontSize?: string; + lineHeight?: string; + fontWeight?: string; + letterSpacing?: string; + wordSpacing?: string; +} + +/** + * Build epub.js theme rules for the hidden preload walker using the same core + * typography metrics as the visible rendition. + */ +export function buildWalkerThemeRules(theme: WalkerThemeSnapshot): Record> { + const bodyRules: Record = { + color: theme.foreground, + 'background-color': theme.base, + }; + + if (theme.fontFamily) bodyRules['font-family'] = theme.fontFamily; + if (theme.fontSize) bodyRules['font-size'] = theme.fontSize; + if (theme.lineHeight) bodyRules['line-height'] = theme.lineHeight; + if (theme.fontWeight) bodyRules['font-weight'] = theme.fontWeight; + if (theme.letterSpacing) bodyRules['letter-spacing'] = theme.letterSpacing; + if (theme.wordSpacing) bodyRules['word-spacing'] = theme.wordSpacing; + + return { body: bodyRules }; +} diff --git a/src/lib/server/tts/segments-manifest.ts b/src/lib/server/tts/segments-manifest.ts index a422391..2faf304 100644 --- a/src/lib/server/tts/segments-manifest.ts +++ b/src/lib/server/tts/segments-manifest.ts @@ -1,10 +1,10 @@ -import { compareSegmentLocators, locatorGroupKey } from '@/lib/shared/tts-locator'; +import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator'; import type { TTSSegmentLocator, TTSSegmentVariant, } from '@/types/client'; -export { locatorGroupKey }; +export { locatorGroupKey, locatorIdentityKey }; export const DEFAULT_PAGE_SIZE = 150; export const MIN_PAGE_SIZE = 25; diff --git a/src/lib/server/tts/segments.ts b/src/lib/server/tts/segments.ts index 480e5ce..1c291c2 100644 --- a/src/lib/server/tts/segments.ts +++ b/src/lib/server/tts/segments.ts @@ -54,20 +54,54 @@ export function normalizeSegmentText(text: string): string { return preprocessSentenceForAudio(text || '').trim(); } +/** + * Validate and shape a locator for persistence. EPUB locators MUST carry the + * stable spine coordinates (`spineHref`, `spineIndex`, `charOffset`) — the + * legacy CFI-only shape is rejected (returns null) so we never store a + * viewport-dependent locator. PDF and HTML branches are unchanged. + */ export function normalizeLocator(locator: TTSSegmentLocator | undefined): TTSSegmentLocator | null { if (!locator) return null; - const normalized: TTSSegmentLocator = {}; - if (typeof locator.page === 'number' && Number.isFinite(locator.page)) { - normalized.page = Math.max(1, Math.floor(locator.page)); + if (locator.readerType === 'pdf') { + if (typeof locator.page !== 'number' || !Number.isFinite(locator.page)) return null; + return { + readerType: 'pdf', + page: Math.max(1, Math.floor(locator.page)), + }; } - if (typeof locator.location === 'string' && locator.location.trim()) { - normalized.location = locator.location.trim(); + if (locator.readerType === 'html') { + if (typeof locator.location !== 'string' || !locator.location.trim()) return null; + return { + readerType: 'html', + location: locator.location.trim(), + }; } - if (locator.readerType === 'pdf' || locator.readerType === 'epub' || locator.readerType === 'html') { - normalized.readerType = locator.readerType; + if (locator.readerType === 'epub') { + const spineHref = typeof locator.spineHref === 'string' ? locator.spineHref.trim() : ''; + const spineIndex = typeof locator.spineIndex === 'number' && Number.isFinite(locator.spineIndex) + ? Math.max(0, Math.floor(locator.spineIndex)) + : -1; + const charOffset = typeof locator.charOffset === 'number' && Number.isFinite(locator.charOffset) + ? Math.max(0, Math.floor(locator.charOffset)) + : -1; + if (!spineHref || spineIndex < 0 || charOffset < 0) { + // Reject draft/legacy EPUB locators that lack stable coordinates. The + // client is expected to resolve these via the spine-coordinates helper + // before posting. + return null; + } + const normalized: TTSSegmentLocator = { + readerType: 'epub', + spineHref, + spineIndex, + charOffset, + }; + if (typeof locator.cfi === 'string' && locator.cfi.trim()) { + normalized.cfi = locator.cfi.trim(); + } + return normalized; } - if (Object.keys(normalized).length === 0) return null; - return normalized; + return null; } export function locatorFingerprint(locator: TTSSegmentLocator | null): string { diff --git a/src/lib/shared/tts-epub-preload.ts b/src/lib/shared/tts-epub-preload.ts new file mode 100644 index 0000000..36858ea --- /dev/null +++ b/src/lib/shared/tts-epub-preload.ts @@ -0,0 +1,38 @@ +import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan'; +import { normalizeEpubLocationToken } from '@/lib/shared/tts-locator'; + +export interface EpubWalkerLocationItem { + cfi: string; +} + +/** + * Keep the EPUB depth contract explicit: + * - `maxDepth` counts the current location as depth 1 + * - upcoming preload targets are therefore `maxDepth - 1` + */ +export function selectUpcomingWalkerItems( + locationItems: readonly T[], + currentCfi: string, + maxDepth: number, +): T[] { + const currentToken = normalizeEpubLocationToken(String(currentCfi || '')); + const filtered = locationItems.filter((item) => + normalizeEpubLocationToken(item.cfi) !== currentToken, + ); + const targetDepth = Math.max(0, maxDepth - 1); + return filtered.slice(0, targetDepth); +} + +/** + * Build the canonical source-unit list used for walker-based EPUB planning. + * When smart splitting is enabled we seed with live context units + * (previous/current) so walker boundary behavior aligns with setText. + */ +export function buildWalkerPlanningSourceUnits( + smartSentenceSplitting: boolean, + contextUnits: readonly CanonicalTtsSourceUnit[], + upcomingUnits: readonly CanonicalTtsSourceUnit[], +): CanonicalTtsSourceUnit[] { + if (!smartSentenceSplitting) return [...upcomingUnits]; + return [...contextUnits, ...upcomingUnits]; +} diff --git a/src/lib/shared/tts-locator.ts b/src/lib/shared/tts-locator.ts index 4d874e1..e37d383 100644 --- a/src/lib/shared/tts-locator.ts +++ b/src/lib/shared/tts-locator.ts @@ -1,11 +1,22 @@ -import type { TTSSegmentLocator } from '@/types/client'; +import { + isHtmlLocator, + isPdfLocator, + isStableEpubLocator, + type TTSSegmentLocator, +} from '@/types/client'; import type { TTSLocation } from '@/types/tts'; -const naturalLocationCollator = new Intl.Collator(undefined, { - numeric: true, - sensitivity: 'base', -}); +export function normalizeTtsLocationKey(location: TTSLocation): string { + return typeof location === 'number' ? `num:${location}` : `str:${location}`; +} +/** + * Normalize an EPUB CFI string for whitespace and step-marker variations so two + * CFIs from the same epub.js rendition can be compared by equality. This is + * only useful for in-flight CFI deduplication (same device, same render); it + * does NOT make CFIs stable across viewports — that's what the spine + * coordinates on the locator are for. + */ export function normalizeEpubLocationToken(location: string): string { return location .trim() @@ -13,45 +24,100 @@ export function normalizeEpubLocationToken(location: string): string { .replace(/\s+/g, ''); } -export function normalizeTtsLocationKey(location: TTSLocation): string { - return typeof location === 'number' ? `num:${location}` : `str:${location}`; -} - +/** + * Coarse grouping key used by the sidebar to bucket rows under a chapter + * heading. EPUB groups by spine item (chapter/section), which is + * viewport-independent. PDF groups by page. HTML groups by the free-form + * location string. Multiple rows in the same chapter share this key — that's + * the whole point. + * + * **Do NOT use for storage dedupe.** For that you want `locatorIdentityKey`, + * which carries the full per-row identity (including `charOffset` for EPUB). + * + * Legacy EPUB rows that only carry a CFI (no spine coords) fall back to a + * group keyed by the raw CFI string — they keep working in isolation but will + * not co-group with new-shape rows. Such rows are expected to disappear once + * the user clears the legacy manifest. + */ export function locatorGroupKey(locator: TTSSegmentLocator | null): string { if (!locator) return 'none'; - const page = typeof locator.page === 'number' && Number.isFinite(locator.page) - ? String(Math.floor(locator.page)) - : ''; - const location = typeof locator.location === 'string' ? locator.location : ''; - const readerType = locator.readerType || ''; - return `p:${page}|l:${location}|r:${readerType}`; + if (isStableEpubLocator(locator)) { + return `epub:${locator.spineIndex}:${locator.spineHref}`; + } + if (isPdfLocator(locator)) { + return `pdf:${Math.floor(locator.page)}`; + } + if (isHtmlLocator(locator)) { + return `html:${locator.location}`; + } + // Legacy / draft fallback. Keeps the row identifiable but does not promise + // cross-viewport stability. + const readerType = locator.readerType || '?'; + const fallback = locator.location ?? (typeof locator.page === 'number' ? String(locator.page) : ''); + return `legacy:${readerType}:${fallback}`; } -export function compareLocationTokens(a: string, b: string): number { - return naturalLocationCollator.compare( - normalizeEpubLocationToken(a), - normalizeEpubLocationToken(b), - ); +/** + * Per-row identity key. Unlike `locatorGroupKey`, this includes the full + * locator-level identity (e.g. `charOffset` for EPUB) so two rows at different + * positions inside the same spine item never collapse into one entry. Used by + * the server-side manifest aggregator and anywhere we need "is this the same + * persisted row?" — not for sidebar chapter buckets. + */ +export function locatorIdentityKey(locator: TTSSegmentLocator | null): string { + if (!locator) return 'none'; + if (isStableEpubLocator(locator)) { + return `epub:${locator.spineIndex}:${locator.spineHref}:${locator.charOffset}`; + } + if (isPdfLocator(locator)) { + return `pdf:${Math.floor(locator.page)}`; + } + if (isHtmlLocator(locator)) { + return `html:${locator.location}`; + } + const readerType = locator.readerType || '?'; + const fallback = locator.location ?? (typeof locator.page === 'number' ? String(locator.page) : ''); + return `legacy:${readerType}:${fallback}`; } +function readerTypeRank(readerType: string | undefined): number { + if (readerType === 'epub') return 0; + if (readerType === 'pdf') return 1; + if (readerType === 'html') return 2; + return 3; +} + +/** + * Total order over locators. Within a readerType: + * - EPUB (stable shape): (spineIndex, charOffset). Both numeric — stable + * across viewports. + * - PDF: page number. + * - HTML: location string (lexicographic). + * Across readerTypes (rarely mixed in practice): stable rank by readerType. + * Null locators sort last. + */ export function compareSegmentLocators( a: TTSSegmentLocator | null, b: TTSSegmentLocator | null, ): number { - const aPage = typeof a?.page === 'number' && Number.isFinite(a.page) - ? Math.floor(a.page) - : Number.MAX_SAFE_INTEGER; - const bPage = typeof b?.page === 'number' && Number.isFinite(b.page) - ? Math.floor(b.page) - : Number.MAX_SAFE_INTEGER; - if (aPage !== bPage) return aPage - bPage; - - const aLocation = typeof a?.location === 'string' ? a.location : ''; - const bLocation = typeof b?.location === 'string' ? b.location : ''; - const byLocation = compareLocationTokens(aLocation, bLocation); - if (byLocation !== 0) return byLocation; - - const aReaderType = a?.readerType || ''; - const bReaderType = b?.readerType || ''; - return aReaderType.localeCompare(bReaderType); + if (!a && !b) return 0; + if (!a) return 1; + if (!b) return -1; + if (a.readerType !== b.readerType) { + return readerTypeRank(a.readerType) - readerTypeRank(b.readerType); + } + if (isStableEpubLocator(a) && isStableEpubLocator(b)) { + if (a.spineIndex !== b.spineIndex) return a.spineIndex - b.spineIndex; + if (a.charOffset !== b.charOffset) return a.charOffset - b.charOffset; + return a.spineHref.localeCompare(b.spineHref); + } + if (isPdfLocator(a) && isPdfLocator(b)) { + return Math.floor(a.page) - Math.floor(b.page); + } + if (isHtmlLocator(a) && isHtmlLocator(b)) { + return a.location.localeCompare(b.location); + } + // One or both are legacy/draft — fall back to grouped-key compare so the + // sort is at least deterministic and self-consistent. + return locatorGroupKey(a).localeCompare(locatorGroupKey(b)); } diff --git a/src/lib/shared/tts-segment-plan.ts b/src/lib/shared/tts-segment-plan.ts index d674544..e2aa1ba 100644 --- a/src/lib/shared/tts-segment-plan.ts +++ b/src/lib/shared/tts-segment-plan.ts @@ -57,7 +57,7 @@ const normalizeSourceText = (text: string): string => .replace(/\s+/g, ' ') .trim(); -const normalizeSegmentIdentityText = (text: string): string => +export const normalizeSegmentIdentityText = (text: string): string => preprocessSentenceForAudio(text) .toLowerCase() .replace(/\s+/g, ' ') @@ -72,7 +72,16 @@ const stableHash = (value: string): string => { return hash.toString(16).padStart(8, '0'); }; -const buildSegmentKey = (keyPrefix: string, text: string): string => +/** + * Compose a key prefix in the canonical form used everywhere segmentKeys are + * minted. Keep this in lock-step with the inline prefixes in TTSContext. + */ +export const buildSegmentKeyPrefix = ( + documentId: string | null | undefined, + readerType: ReaderType, +): string => `${documentId || 'document'}:${readerType}:v1`; + +export const buildSegmentKey = (keyPrefix: string, text: string): string => [ keyPrefix, stableHash(normalizeSegmentIdentityText(text)), diff --git a/src/types/client.ts b/src/types/client.ts index 00a5463..bf2a623 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -94,10 +94,78 @@ export interface TTSSegmentSettings { ttsInstructions?: string; } +export type TTSReaderType = 'pdf' | 'epub' | 'html'; + +/** + * Locator describing where a TTS segment came from inside a document. + * + * Field usage by readerType: + * - PDF: `page` (1-based). + * - HTML: `location` (free-form fragment id / scroll anchor). + * - EPUB: **stable book coordinates** — `spineHref`, `spineIndex`, `charOffset`. + * `cfi` is a best-effort jump hint only; it is NOT used for identity, + * grouping, sorting, or matching. The stable coordinates are the same + * across devices and window sizes. + * + * The interface is structurally permissive for backwards compatibility with + * in-flight code paths, but the server-side `normalizeLocator` enforces the + * required fields per readerType before persisting. Use the + * `isStableEpubLocator` / `isPdfLocator` / `isHtmlLocator` guards at read sites + * that need a particular shape. + */ export interface TTSSegmentLocator { + readerType?: TTSReaderType; + // PDF / legacy page?: number; + // HTML / legacy EPUB CFI (kept for in-flight drafts; not persisted for EPUB) location?: string; - readerType?: 'pdf' | 'epub' | 'html'; + // Stable EPUB coordinates + spineHref?: string; + spineIndex?: number; + charOffset?: number; + /** Best-effort jump hint for EPUB; not part of identity/sort/group. */ + cfi?: string; +} + +export function isPdfLocator( + locator: TTSSegmentLocator | null | undefined, +): locator is TTSSegmentLocator & { readerType: 'pdf'; page: number } { + return !!locator + && locator.readerType === 'pdf' + && typeof locator.page === 'number' + && Number.isFinite(locator.page); +} + +export function isHtmlLocator( + locator: TTSSegmentLocator | null | undefined, +): locator is TTSSegmentLocator & { readerType: 'html'; location: string } { + return !!locator + && locator.readerType === 'html' + && typeof locator.location === 'string' + && locator.location.length > 0; +} + +/** + * Narrow to a fully-stable EPUB locator. Returns false for EPUB drafts that + * only carry a CFI — those must be resolved to spine coordinates before being + * sent to the server. + */ +export function isStableEpubLocator( + locator: TTSSegmentLocator | null | undefined, +): locator is TTSSegmentLocator & { + readerType: 'epub'; + spineHref: string; + spineIndex: number; + charOffset: number; +} { + return !!locator + && locator.readerType === 'epub' + && typeof locator.spineHref === 'string' + && locator.spineHref.length > 0 + && typeof locator.spineIndex === 'number' + && Number.isFinite(locator.spineIndex) + && typeof locator.charOffset === 'number' + && Number.isFinite(locator.charOffset); } export interface TTSSegmentInput { @@ -145,6 +213,15 @@ export interface TTSSegmentVariant { export interface TTSSegmentRow { segmentIndex: number; + /** + * Content-stable identity for this segment, derived from the normalized + * sentence text on the client (see `buildSegmentKey` in + * `lib/shared/tts-segment-plan.ts`). The sidebar uses this to merge + * locally-synthesized current-page rows with persisted manifest rows of the + * same content, so audio/variants attach to the visible text row instead of + * showing as a separate listing. + */ + segmentKey: string | null; locator: TTSSegmentLocator | null; variants: TTSSegmentVariant[]; } diff --git a/src/types/tts.ts b/src/types/tts.ts index 482168f..b716396 100644 --- a/src/types/tts.ts +++ b/src/types/tts.ts @@ -55,11 +55,27 @@ export interface TTSSentenceAlignment { words: TTSSentenceWord[]; } +export interface EpubRenderedLocationWalkItem { + /** Page-start CFI from the rendition — best-effort jump hint only. */ + cfi: string; + /** Plain text content of the rendered page chunk. */ + text: string; + /** Spine item href the chunk belongs to (stable across viewports). */ + spineHref: string; + /** Ordinal of the spine item within the book (stable across viewports). */ + spineIndex: number; + /** + * Offset (in normalized character space) of this chunk's start inside the + * spine item's plain text. Stable across viewports. + */ + chunkOffset: number; +} + export type EpubRenderedLocationWalker = ( startCfi: string, depth: number, signal: AbortSignal, -) => Promise>; +) => Promise; // Supported output formats for generated audiobooks export type TTSAudiobookFormat = 'mp3' | 'm4b'; diff --git a/tests/unit/canonicalize-epub-segment.spec.ts b/tests/unit/canonicalize-epub-segment.spec.ts new file mode 100644 index 0000000..42a02b8 --- /dev/null +++ b/tests/unit/canonicalize-epub-segment.spec.ts @@ -0,0 +1,153 @@ +import { expect, test } from '@playwright/test'; + +import { + canonicalizeEpubSegmentAgainstSpineText, + canonicalizeEpubSegmentsAgainstSpineText, +} from '../../src/lib/client/epub/canonicalize-epub-segment'; +import { planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan'; + +test.describe('canonicalizeEpubSegmentAgainstSpineText', () => { + test('maps an exact sentence to the canonical segment identity', () => { + const spineText = [ + 'First section sentence with enough words to stand alone.', + 'Second section sentence with enough words to stand alone.', + 'Third section sentence with enough words to stand alone.', + ].join('\n'); + + const result = canonicalizeEpubSegmentAgainstSpineText({ + segmentText: 'Second section sentence with enough words to stand alone.', + spineText, + spineHref: 'OEBPS/ch01.xhtml', + spineIndex: 1, + hintCharOffset: 70, + keyPrefix: 'doc-1:epub:v1', + maxBlockLength: 90, + }); + + expect(result).not.toBeNull(); + expect(result?.locator.readerType).toBe('epub'); + expect(result?.locator.spineHref).toBe('OEBPS/ch01.xhtml'); + expect(result?.locator.spineIndex).toBe(1); + expect(result?.text).toContain('Second section sentence'); + expect(result?.segmentKey).toContain('doc-1:epub:v1:'); + }); + + test('uses hintCharOffset to choose the nearest repeated exact match', () => { + const repeated = 'Echo phrase repeated with enough words to stand alone.'; + const spineText = [ + repeated, + 'Middle bridge sentence that separates repeated text clearly.', + repeated, + ].join('\n'); + + const plan = planCanonicalTtsSegments( + [{ sourceKey: 'spine:2:OEBPS/ch02.xhtml', text: spineText }], + { readerType: 'epub', maxBlockLength: 90, keyPrefix: 'doc-2:epub:v1' }, + ); + const matching = plan.segments.filter((segment) => segment.text === repeated); + expect(matching.length).toBeGreaterThanOrEqual(2); + const later = matching[matching.length - 1]; + + const result = canonicalizeEpubSegmentAgainstSpineText({ + segmentText: repeated, + spineText, + spineHref: 'OEBPS/ch02.xhtml', + spineIndex: 2, + hintCharOffset: later.startAnchor.offset, + keyPrefix: 'doc-2:epub:v1', + maxBlockLength: 90, + }); + + expect(result).not.toBeNull(); + expect(result?.segmentKey).toBe(later.key); + expect(result?.locator.charOffset).toBe(later.startAnchor.offset); + }); + + test('falls back to hint-window selection when text does not match exactly', () => { + const spineText = [ + 'Opening sentence that should map to block one.', + 'Middle sentence that should map to block two.', + 'Closing sentence that should map to block three.', + ].join('\n'); + + const plan = planCanonicalTtsSegments( + [{ sourceKey: 'spine:3:OEBPS/ch03.xhtml', text: spineText }], + { readerType: 'epub', maxBlockLength: 90, keyPrefix: 'doc-3:epub:v1' }, + ); + const target = plan.segments.find((segment) => + segment.text.includes('Middle sentence'), + ); + expect(target).toBeTruthy(); + + const result = canonicalizeEpubSegmentAgainstSpineText({ + segmentText: 'Non-matching walker boundary text fragment', + spineText, + spineHref: 'OEBPS/ch03.xhtml', + spineIndex: 3, + hintCharOffset: target!.startAnchor.offset, + keyPrefix: 'doc-3:epub:v1', + maxBlockLength: 90, + }); + + expect(result).not.toBeNull(); + expect(result?.segmentKey).toBe(target!.key); + expect(result?.segmentIndex).toBe(target!.ordinal); + }); +}); + +test.describe('canonicalizeEpubSegmentsAgainstSpineText', () => { + test('maps overlap-boundary drift sentences to forward canonical segments', () => { + const sourceSentences = [ + 'The star was particularly bright when the station lights switched off for cycle night.', + 'After losing his staring match, the night janitor muttered and walked on.', + 'You might have called it aqua, or perhaps a faded green under glass.', + 'A titch too purple for hot pink, it was still impossible to ignore.', + 'Needing no pole or wire to hold them aloft, the banners drifted above the plaza.', + 'He would have been confused to hear that this was considered a calm evening.', + ]; + const spineText = sourceSentences.join('\n'); + const plan = planCanonicalTtsSegments( + [{ sourceKey: 'spine:8:OEBPS/ch08.xhtml', text: spineText }], + { readerType: 'epub', maxBlockLength: 80, keyPrefix: 'doc-8:epub:v1' }, + ); + expect(plan.segments.length).toBeGreaterThanOrEqual(6); + + const driftedLocalSentences = [ + plan.segments[0].text, + plan.segments[1].text, + plan.segments[2].text, + plan.segments[3].text, + // Mimics a resize boundary split that no longer matches canonical text. + 'Boundary drift fragment that does not exactly match any canonical segment.', + plan.segments[5].text, + ]; + const hints = [ + plan.segments[0].startAnchor.offset, + plan.segments[1].startAnchor.offset, + plan.segments[2].startAnchor.offset, + plan.segments[3].startAnchor.offset, + plan.segments[3].startAnchor.offset + 1, + plan.segments[5].startAnchor.offset, + ]; + + const mapped = canonicalizeEpubSegmentsAgainstSpineText({ + segmentTexts: driftedLocalSentences, + hintCharOffsets: hints, + spineText, + spineHref: 'OEBPS/ch08.xhtml', + spineIndex: 8, + keyPrefix: 'doc-8:epub:v1', + maxBlockLength: 80, + }); + + expect(mapped[0]?.segmentKey).toBe(plan.segments[0].key); + expect(mapped[1]?.segmentKey).toBe(plan.segments[1].key); + expect(mapped[2]?.segmentKey).toBe(plan.segments[2].key); + expect(mapped[3]?.segmentKey).toBe(plan.segments[3].key); + // Core regression: despite hinting near segment 3, the mismatch sentence + // maps to segment 4 (forward-only cursor), not backward to segment 3. + expect(mapped[4]?.segmentIndex).toBe(4); + expect(mapped[4]?.segmentKey).toBe(plan.segments[4].key); + expect(mapped[5]?.segmentKey).toBe(plan.segments[5].key); + }); +}); diff --git a/tests/unit/epub-spine-coordinates.spec.ts b/tests/unit/epub-spine-coordinates.spec.ts new file mode 100644 index 0000000..2d8b9a5 --- /dev/null +++ b/tests/unit/epub-spine-coordinates.spec.ts @@ -0,0 +1,408 @@ +import { expect, test } from '@playwright/test'; +import type { Book } from 'epubjs'; +import { + buildEpubLocator, + buildEpubLocatorFromChunk, + findSegmentOffset, + getSpineItemPlainText, + invalidateSpinePlainTextCache, + resolveMonotonicSentenceOffsets, + resolveSpineFromCfi, +} from '../../src/lib/client/epub/spine-coordinates'; +import { isStableEpubLocator } from '../../src/types/client'; + +interface FakeSection { + index: number; + href: string; + cfiBase: string; + __text: string; + // epubjs's real `section.load()` resolves to an `` Element, but + // historically the fake returned a Document. We accept both so the helper's + // defensive shape-handling can be tested. + load: (request?: unknown) => Promise; + unload: () => void; +} + +function makeFakeBook(items: Array<{ index: number; href: string; cfiBase: string; text: string }>) { + // Mirror what epubjs's `Section.load()` actually does: resolve to the + // spine item's `` Element (NOT a Document). The helper has to query + // `` inside it — that's the contract this test fixture pins down. + const sections: FakeSection[] = items.map((item) => ({ + index: item.index, + href: item.href, + cfiBase: item.cfiBase, + __text: item.text, + load: async () => { + if (typeof document === 'undefined') { + // Node fallback: hand back a shape with a `body` getter so the + // helper's body-aware branch still works. + return { + querySelector: (sel: string) => (sel === 'body' ? { textContent: item.text } : null), + textContent: item.text, + } as unknown as Element; + } + const html = document.createElement('html'); + const body = document.createElement('body'); + body.textContent = item.text; + html.appendChild(body); + return html as unknown as Document; + }, + unload: () => {}, + })); + + const get = (target: unknown) => { + if (typeof target === 'number') { + return sections.find((s) => s.index === target) ?? null; + } + if (typeof target === 'string') { + // Match by href substring or by cfiBase substring (epubcfi(/6/4!...) → '/6/4'). + const byHref = sections.find((s) => s.href === target || target.includes(s.href)); + if (byHref) return byHref; + const byCfiBase = sections.find((s) => target.includes(s.cfiBase)); + if (byCfiBase) return byCfiBase; + } + return null; + }; + + const book = { + spine: { get, spineItems: sections }, + load: () => Promise.resolve(undefined), + } as unknown as Book; + return { book, sections }; +} + +test.describe('findSegmentOffset', () => { + test('finds an offset for matching text', () => { + const spineText = 'Hello world. This is a test paragraph for offsets.'; + const offset = findSegmentOffset(spineText, 'this is a test'); + expect(offset).toBeGreaterThanOrEqual(0); + }); + + test('uses the hint to disambiguate repeated phrases', () => { + const spineText = 'apple banana apple banana apple banana'; + // First occurrence + const first = findSegmentOffset(spineText, 'apple banana', 0); + // Hint past the first occurrence + const second = findSegmentOffset(spineText, 'apple banana', first + 1); + expect(second).toBeGreaterThan(first); + }); + + test('returns -1 when the segment text is not present', () => { + expect(findSegmentOffset('hello world', 'goodbye')).toBe(-1); + }); + + test('falls back to a from-start search when the hint overshoots', () => { + const spineText = 'apple banana cherry'; + const offset = findSegmentOffset(spineText, 'apple', 100); + expect(offset).toBe(0); + }); +}); + +test.describe('resolveSpineFromCfi', () => { + test('returns spine identity for a CFI inside a known spine', () => { + const { book } = makeFakeBook([ + { index: 2, href: 'OEBPS/ch02.xhtml', cfiBase: '/6/4', text: 'chapter 2 contents' }, + { index: 3, href: 'OEBPS/ch03.xhtml', cfiBase: '/6/6', text: 'chapter 3 contents' }, + ]); + const resolved = resolveSpineFromCfi(book, 'epubcfi(/6/4!/4:0)'); + expect(resolved).toEqual({ href: 'OEBPS/ch02.xhtml', index: 2 }); + }); + + test('returns null when the spine.get call throws', () => { + const broken = { + spine: { + get: () => { throw new Error('boom'); }, + spineItems: [], + }, + } as unknown as Book; + expect(resolveSpineFromCfi(broken, 'epubcfi(/6/4!/4:0)')).toBeNull(); + }); + + test('returns null for null/undefined input', () => { + const { book } = makeFakeBook([]); + expect(resolveSpineFromCfi(book, null)).toBeNull(); + expect(resolveSpineFromCfi(book, undefined)).toBeNull(); + }); +}); + +test.describe('buildEpubLocator', () => { + test('produces a stable locator with charOffset within the spine item', async () => { + const { book } = makeFakeBook([ + { + index: 2, + href: 'OEBPS/ch02.xhtml', + cfiBase: '/6/4', + text: 'First paragraph. The quick brown fox jumps over the lazy dog. Final words here.', + }, + ]); + + invalidateSpinePlainTextCache(book); + const locator = await buildEpubLocator( + book, + 'epubcfi(/6/4!/4:0)', + 'The quick brown fox', + ); + + expect(locator).not.toBeNull(); + expect(isStableEpubLocator(locator)).toBe(true); + if (!locator || !isStableEpubLocator(locator)) return; // narrow for TS + expect(locator.spineHref).toBe('OEBPS/ch02.xhtml'); + expect(locator.spineIndex).toBe(2); + expect(locator.charOffset).toBeGreaterThan(0); + expect(locator.cfi).toBe('epubcfi(/6/4!/4:0)'); + }); + + test('returns null when the CFI does not resolve to any spine item', async () => { + const { book } = makeFakeBook([ + { index: 0, href: 'OEBPS/ch00.xhtml', cfiBase: '/6/2', text: 'only chapter' }, + ]); + const locator = await buildEpubLocator(book, 'epubcfi(/99/99!/4:0)', 'nope'); + expect(locator).toBeNull(); + }); + + test('same content text yields identical locators across two calls (stability)', async () => { + const { book } = makeFakeBook([ + { + index: 4, + href: 'OEBPS/ch04.xhtml', + cfiBase: '/6/8', + text: 'Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu.', + }, + ]); + const a = await buildEpubLocator(book, 'epubcfi(/6/8!/4:0)', 'gamma delta epsilon'); + const b = await buildEpubLocator(book, 'epubcfi(/6/8!/4:0)', 'gamma delta epsilon'); + expect(a).toEqual(b); + }); +}); + +test.describe('buildEpubLocatorFromChunk', () => { + test('uses the chunk anchor offset as a hint to disambiguate repeated text', () => { + const spineText = 'foo bar foo bar foo bar'; + const anchorEarly = { + spineHref: 'a.xhtml', + spineIndex: 0, + charOffset: 0, + spineText, + }; + const anchorLate = { + spineHref: 'a.xhtml', + spineIndex: 0, + charOffset: 8, // past the first "foo bar" + spineText, + }; + const early = buildEpubLocatorFromChunk(anchorEarly, 'foo bar'); + const late = buildEpubLocatorFromChunk(anchorLate, 'foo bar'); + expect(early.charOffset).toBeLessThan(late.charOffset ?? Infinity); + }); + + test('NEVER returns an offset before the chunk anchor (forward-only)', () => { + // Regression: a segment text that recurs earlier in the chapter must not + // get persisted with the earlier offset. The forward-only contract is + // what keeps later-page rows from interleaving with earlier pages in the + // sidebar's sort. + const spineText = 'Yes I said. ' + 'Filler. '.repeat(20) + 'Yes I said. The actual page sentence.'; + const earlyOccurrence = spineText.indexOf('Yes I said.'); + const lateOccurrence = spineText.indexOf('Yes I said.', earlyOccurrence + 1); + expect(earlyOccurrence).toBeLessThan(lateOccurrence); // sanity + + const anchor = { + spineHref: 'a.xhtml', + spineIndex: 0, + // Anchored ~at the actual page (past the early occurrence). + charOffset: lateOccurrence - 5, + spineText, + }; + const locator = buildEpubLocatorFromChunk(anchor, 'Yes I said.'); + expect(locator.charOffset).toBeGreaterThanOrEqual(anchor.charOffset); + // Specifically: must be at the late occurrence, NOT the early one. + expect(locator.charOffset).toBeGreaterThan(earlyOccurrence); + }); + + test('holds the anchor offset when segment text is not found ahead', () => { + const anchor = { + spineHref: 'a.xhtml', + spineIndex: 0, + charOffset: 100, + spineText: 'short text here', + }; + const locator = buildEpubLocatorFromChunk(anchor, 'definitely not present'); + expect(locator.charOffset).toBe(100); // holds at anchor, never goes earlier + }); +}); + +test.describe('buildEpubLocator anchored-search regression', () => { + // Pins the contract for the server-side resolver path: when called with + // a `chunkText` argument representing the current rendered page, the + // returned locator's `charOffset` is **at or after** the chunk's position + // in the spine — never the segment's earliest occurrence in the chapter. + test('locator for a recurring sentence sits at the page anchor, not the chapter-early echo', async () => { + if (typeof document === 'undefined') return; // DOM-only via fixture + const earlyEcho = 'The Lighthouse.'; + const pageText = 'On Tuesday, the visitors arrived. The Lighthouse. They marveled at the height.'; + const chapterText = `${earlyEcho} ${'Filler sentence. '.repeat(40)}${pageText}`; + const { book } = makeFakeBook([ + { index: 0, href: 'ch.xhtml', cfiBase: '/6/2', text: chapterText }, + ]); + invalidateSpinePlainTextCache(book); + // Search the SAME segment text. The resolver passes the page text as + // chunkText, so the anchor sits at the page's start — not at the + // earlier `earlyEcho` occurrence. + const locator = await buildEpubLocator( + book, + 'epubcfi(/6/2!/4:0)', + 'The Lighthouse.', + pageText, + ); + expect(locator).not.toBeNull(); + if (!locator || !isStableEpubLocator(locator)) return; + // The returned offset must be past where the early echo lives. + const earlyEchoIndex = chapterText.toLowerCase().indexOf(earlyEcho.toLowerCase()); + expect(locator.charOffset).toBeGreaterThan(earlyEchoIndex); + }); +}); + +test.describe('findSegmentOffset fallback contract', () => { + // These tests pin the documented behavior of `findSegmentOffset`. The + // from-start fallback is correct for single-shot lookups but **wrong** in + // a monotonic per-sentence walk — `resolveMonotonicSentenceOffsets` exists + // precisely to avoid this. Keep these tests so the two helpers' contracts + // stay distinct. + test('returns the earliest occurrence when the hint overshoots', () => { + expect(findSegmentOffset('Yes. No. Maybe.', 'yes.', /* hint */ 50)).toBe(0); + }); + + test('falls back to from-start when the forward search misses', () => { + // "echo" appears at index 0. Hint past the only occurrence should still + // find it via the from-start fallback. + expect(findSegmentOffset('echo and silence', 'echo', /* hint */ 10)).toBe(0); + }); +}); + +test.describe('resolveMonotonicSentenceOffsets', () => { + test('returns monotonically non-decreasing offsets for sentences in order', () => { + const spineText = 'The cat sat on the mat. The dog barked loudly. Then it was quiet.'; + const sentences = [ + 'The cat sat on the mat.', + 'The dog barked loudly.', + 'Then it was quiet.', + ]; + const offsets = resolveMonotonicSentenceOffsets(spineText, sentences); + expect(offsets).toHaveLength(3); + for (let i = 1; i < offsets.length; i += 1) { + expect(offsets[i]).toBeGreaterThanOrEqual(offsets[i - 1]); + } + }); + + test('does NOT jump backwards when a later sentence echoes earlier text', () => { + // Regression: a sentence on the current page that happens to contain a + // phrase also present earlier in the chapter (chapter title, refrain, + // etc.) used to take the earlier occurrence's offset via + // `findSegmentOffset`'s from-start fallback, which silently reordered + // the sidebar's synth rows. The monotonic helper must never do that. + const spineText = 'The Lighthouse. Several pages of narrative go here. The Lighthouse stood tall.'; + const sentences = [ + 'Several pages of narrative go here.', // ~at offset 16 + 'The Lighthouse stood tall.', // recurs — must NOT jump back to offset 0 + ]; + const offsets = resolveMonotonicSentenceOffsets(spineText, sentences); + expect(offsets[1]).toBeGreaterThan(offsets[0]); + }); + + test('holds the cursor for a sentence that cannot be found ahead', () => { + const spineText = 'alpha beta gamma delta'; + const sentences = ['gamma', 'something not present', 'delta']; + const offsets = resolveMonotonicSentenceOffsets(spineText, sentences); + // gamma found, missing sentence holds cursor, delta found ahead of it. + expect(offsets[0]).toBeGreaterThan(0); + expect(offsets[1]).toBeGreaterThanOrEqual(offsets[0]); // monotonic + expect(offsets[2]).toBeGreaterThan(offsets[1]); // delta is after gamma + }); + + test('handles empty inputs gracefully', () => { + expect(resolveMonotonicSentenceOffsets('', ['anything'])).toEqual([0]); + expect(resolveMonotonicSentenceOffsets('hello world', [])).toEqual([]); + expect(resolveMonotonicSentenceOffsets('hello world', ['', '', ''])).toEqual([0, 0, 0]); + }); + + test('repeated phrase across the page is resolved at successive occurrences', () => { + // Three "yes." sentences in a row should each pick the next occurrence, + // not all pile onto the first one. + const spineText = 'yes. yes. yes. end.'; + const sentences = ['yes.', 'yes.', 'yes.']; + const offsets = resolveMonotonicSentenceOffsets(spineText, sentences); + expect(new Set(offsets).size).toBe(3); // all distinct + expect(offsets[0]).toBeLessThan(offsets[1]); + expect(offsets[1]).toBeLessThan(offsets[2]); + }); + + test('normalizes whitespace and casing the same as segmentKey identity', () => { + // The helper uses `normalizeSegmentIdentityText` internally so it agrees + // with the segmentKey hash space. Different casing / whitespace must + // still locate the sentence. + const spineText = 'Hello World. More content here.'; + const sentences = ['hello world.', 'MORE content here.']; + const offsets = resolveMonotonicSentenceOffsets(spineText, sentences); + expect(offsets[0]).toBe(0); + expect(offsets[1]).toBeGreaterThan(offsets[0]); + }); +}); + +test.describe('getSpineItemPlainText shape handling', () => { + // Regression: epubjs's `Section.load()` resolves to the spine item's + // `` Element (NOT a Document). Reading `loaded.body` directly was + // returning undefined for every spine item, so spineText came back '' + // and every `findSegmentOffset` returned -1 → every persisted charOffset + // ended up at 0. These tests pin both shapes so a future epubjs upgrade + // (or a defensive rewrite) doesn't quietly break extraction again. + test('extracts text when section.load resolves to an Element', async () => { + // Use the harness fixture which already returns a real element + // when DOM is available. + if (typeof document === 'undefined') { + // Skip in non-DOM environments. + return; + } + const { book } = makeFakeBook([ + { index: 0, href: 'ch.xhtml', cfiBase: '/6/2', text: 'real text from html element' }, + ]); + invalidateSpinePlainTextCache(book); + const text = await getSpineItemPlainText(book, 'ch.xhtml'); + expect(text).toContain('real text'); + }); + + test('extracts text when section.load resolves to a Document-shaped object', async () => { + // Defensive: if a future epubjs version (or a custom shim) returns a + // Document instead of an Element, the helper should still work. + const fakeBook = { + spine: { + get: () => ({ + href: 'ch.xhtml', + load: async () => ({ + body: { textContent: 'doc-shaped content' }, + documentElement: { textContent: 'doc-shaped content' }, + }), + unload: () => {}, + }), + spineItems: [], + }, + load: () => Promise.resolve(undefined), + } as unknown as Parameters[0]; + const text = await getSpineItemPlainText(fakeBook, 'ch.xhtml'); + expect(text).toBe('doc-shaped content'); + }); + + test('returns empty string when section.load resolves to null/undefined', async () => { + const fakeBook = { + spine: { + get: () => ({ + href: 'ch.xhtml', + load: async () => null, + unload: () => {}, + }), + spineItems: [], + }, + load: () => Promise.resolve(undefined), + } as unknown as Parameters[0]; + const text = await getSpineItemPlainText(fakeBook, 'ch.xhtml'); + expect(text).toBe(''); + }); +}); diff --git a/tests/unit/tts-epub-preload.spec.ts b/tests/unit/tts-epub-preload.spec.ts new file mode 100644 index 0000000..c6186be --- /dev/null +++ b/tests/unit/tts-epub-preload.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; + +import type { CanonicalTtsSourceUnit } from '../../src/lib/shared/tts-segment-plan'; +import { + buildWalkerPlanningSourceUnits, + selectUpcomingWalkerItems, +} from '../../src/lib/shared/tts-epub-preload'; + +test.describe('EPUB walker preload helpers', () => { + test('selectUpcomingWalkerItems honors depth as current + (depth-1) upcoming', () => { + const items = [ + { cfi: 'epubcfi(/6/2[;s=a]!/4/2)' }, // same as current after normalization + { cfi: 'epubcfi(/6/4!/4/2)' }, + { cfi: 'epubcfi(/6/6!/4/2)' }, + { cfi: 'epubcfi(/6/8!/4/2)' }, + ]; + + const selected = selectUpcomingWalkerItems(items, 'epubcfi(/6/2!/4/2)', 2); + expect(selected.map((item) => item.cfi)).toEqual([ + 'epubcfi(/6/4!/4/2)', + ]); + }); + + test('selectUpcomingWalkerItems returns empty list when depth <= 1', () => { + const items = [ + { cfi: 'epubcfi(/6/4!/4/2)' }, + { cfi: 'epubcfi(/6/6!/4/2)' }, + ]; + expect(selectUpcomingWalkerItems(items, 'epubcfi(/6/2!/4/2)', 1)).toEqual([]); + expect(selectUpcomingWalkerItems(items, 'epubcfi(/6/2!/4/2)', 0)).toEqual([]); + }); + + test('buildWalkerPlanningSourceUnits includes live context when smart splitting is enabled', () => { + const contextUnits: CanonicalTtsSourceUnit[] = [ + { sourceKey: 'previous:page-a', text: 'prev sentence', locator: null }, + { sourceKey: 'page-a', text: 'current sentence', locator: { readerType: 'epub', location: 'page-a' } }, + ]; + const upcomingUnits: CanonicalTtsSourceUnit[] = [ + { sourceKey: 'page-b', text: 'upcoming one', locator: { readerType: 'epub', location: 'page-b' } }, + { sourceKey: 'page-c', text: 'upcoming two', locator: { readerType: 'epub', location: 'page-c' } }, + ]; + + const planned = buildWalkerPlanningSourceUnits(true, contextUnits, upcomingUnits); + expect(planned.map((item) => item.sourceKey)).toEqual([ + 'previous:page-a', + 'page-a', + 'page-b', + 'page-c', + ]); + }); + + test('buildWalkerPlanningSourceUnits uses only upcoming units when smart splitting is disabled', () => { + const contextUnits: CanonicalTtsSourceUnit[] = [ + { sourceKey: 'previous:page-a', text: 'prev sentence', locator: null }, + { sourceKey: 'page-a', text: 'current sentence', locator: { readerType: 'epub', location: 'page-a' } }, + ]; + const upcomingUnits: CanonicalTtsSourceUnit[] = [ + { sourceKey: 'page-b', text: 'upcoming one', locator: { readerType: 'epub', location: 'page-b' } }, + ]; + + const planned = buildWalkerPlanningSourceUnits(false, contextUnits, upcomingUnits); + expect(planned.map((item) => item.sourceKey)).toEqual(['page-b']); + }); +}); diff --git a/tests/unit/tts-locator.spec.ts b/tests/unit/tts-locator.spec.ts new file mode 100644 index 0000000..1cc1d8e --- /dev/null +++ b/tests/unit/tts-locator.spec.ts @@ -0,0 +1,150 @@ +import { expect, test } from '@playwright/test'; +import { + compareSegmentLocators, + locatorGroupKey, + locatorIdentityKey, + normalizeEpubLocationToken, +} from '../../src/lib/shared/tts-locator'; +import { + isHtmlLocator, + isPdfLocator, + isStableEpubLocator, + type TTSSegmentLocator, +} from '../../src/types/client'; + +const epubLocator = ( + spineHref: string, + spineIndex: number, + charOffset: number, + cfi?: string, +): TTSSegmentLocator => ({ + readerType: 'epub', + spineHref, + spineIndex, + charOffset, + ...(cfi ? { cfi } : {}), +}); + +test.describe('locatorGroupKey', () => { + test('groups stable EPUB locators by spine index and href', () => { + expect(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0))).toBe('epub:2:OEBPS/ch02.xhtml'); + // Different charOffset, same spine → same group (sidebar bucket is chapter-sized). + expect(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 1024))) + .toBe(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0))); + // Different spine → different group. + expect(locatorGroupKey(epubLocator('OEBPS/ch03.xhtml', 3, 0))) + .not.toBe(locatorGroupKey(epubLocator('OEBPS/ch02.xhtml', 2, 0))); + }); + + test('groups PDF locators by page', () => { + expect(locatorGroupKey({ readerType: 'pdf', page: 7 })).toBe('pdf:7'); + }); + + test('groups HTML locators by location', () => { + expect(locatorGroupKey({ readerType: 'html', location: '#anchor' })).toBe('html:#anchor'); + }); + + test('returns "none" for null locators', () => { + expect(locatorGroupKey(null)).toBe('none'); + }); + + test('falls back to a legacy group key for EPUB drafts missing spine coords', () => { + // A draft EPUB locator (just a CFI) should not co-group with stable rows. + const draft: TTSSegmentLocator = { readerType: 'epub', location: 'epubcfi(/6/8!/4:0)' }; + expect(locatorGroupKey(draft)).toContain('legacy'); + }); +}); + +test.describe('locatorIdentityKey', () => { + test('two EPUB rows in the same chapter at different charOffsets get distinct identity keys', () => { + // This is the bug that caused page-N rows to swallow page-(N+1) rows in + // the server-side manifest aggregator: the previous code keyed by the + // chapter-coarse groupKey, collapsing distinct rows into a single bucket. + const a = epubLocator('OEBPS/ch02.xhtml', 2, 0); + const b = epubLocator('OEBPS/ch02.xhtml', 2, 1024); + expect(locatorIdentityKey(a)).not.toBe(locatorIdentityKey(b)); + }); + + test('two EPUB rows at the same coordinate get equal identity keys', () => { + // Identity is content-of-locator only; the optional cfi jump hint is + // intentionally not part of identity. + const a = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:0)'); + const b = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:8)'); + expect(locatorIdentityKey(a)).toBe(locatorIdentityKey(b)); + }); + + test('chapter-coarse group key collides across rows that identity-key keeps distinct', () => { + // Sanity-check the relationship between the two helpers. + const a = epubLocator('OEBPS/ch02.xhtml', 2, 0); + const b = epubLocator('OEBPS/ch02.xhtml', 2, 1024); + expect(locatorGroupKey(a)).toBe(locatorGroupKey(b)); // same chapter bucket + expect(locatorIdentityKey(a)).not.toBe(locatorIdentityKey(b)); // distinct rows + }); +}); + +test.describe('compareSegmentLocators', () => { + test('orders EPUB rows by spineIndex then charOffset numerically', () => { + // Spine 10 must come AFTER spine 2 — a lexicographic compare would + // wrongly place "10" before "2". + const a = epubLocator('a.xhtml', 10, 0); + const b = epubLocator('b.xhtml', 2, 0); + expect(compareSegmentLocators(a, b)).toBeGreaterThan(0); + expect(compareSegmentLocators(b, a)).toBeLessThan(0); + }); + + test('within the same spine, orders by charOffset', () => { + const a = epubLocator('OEBPS/ch02.xhtml', 2, 50); + const b = epubLocator('OEBPS/ch02.xhtml', 2, 1000); + expect(compareSegmentLocators(a, b)).toBeLessThan(0); + expect(compareSegmentLocators(b, a)).toBeGreaterThan(0); + }); + + test('treats equal stable EPUB locators as equal regardless of optional cfi hint', () => { + const a = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:0)'); + const b = epubLocator('OEBPS/ch02.xhtml', 2, 50, 'epubcfi(/6/4!/2:8)'); + // cfi is a soft jump hint, not part of identity/sort. + expect(compareSegmentLocators(a, b)).toBe(0); + }); + + test('orders PDF locators by page numerically', () => { + const a: TTSSegmentLocator = { readerType: 'pdf', page: 2 }; + const b: TTSSegmentLocator = { readerType: 'pdf', page: 10 }; + expect(compareSegmentLocators(a, b)).toBeLessThan(0); + }); + + test('null locators sort last', () => { + expect(compareSegmentLocators(null, { readerType: 'pdf', page: 1 })).toBeGreaterThan(0); + expect(compareSegmentLocators({ readerType: 'pdf', page: 1 }, null)).toBeLessThan(0); + expect(compareSegmentLocators(null, null)).toBe(0); + }); +}); + +test.describe('type guards', () => { + test('isStableEpubLocator requires all spine fields', () => { + expect(isStableEpubLocator(epubLocator('a.xhtml', 0, 0))).toBe(true); + expect(isStableEpubLocator({ readerType: 'epub', spineHref: 'a.xhtml' })).toBe(false); + expect(isStableEpubLocator({ readerType: 'epub', location: 'epubcfi(...)' })).toBe(false); + expect(isStableEpubLocator(null)).toBe(false); + expect(isStableEpubLocator({ readerType: 'pdf', page: 1 })).toBe(false); + }); + + test('isPdfLocator narrows PDF rows', () => { + expect(isPdfLocator({ readerType: 'pdf', page: 3 })).toBe(true); + expect(isPdfLocator({ readerType: 'pdf' })).toBe(false); + expect(isPdfLocator(epubLocator('a.xhtml', 0, 0))).toBe(false); + }); + + test('isHtmlLocator narrows HTML rows', () => { + expect(isHtmlLocator({ readerType: 'html', location: '#x' })).toBe(true); + expect(isHtmlLocator({ readerType: 'html', location: '' })).toBe(false); + expect(isHtmlLocator({ readerType: 'pdf', page: 1 })).toBe(false); + }); +}); + +test.describe('normalizeEpubLocationToken', () => { + test('strips whitespace and step markers but preserves identity', () => { + const a = normalizeEpubLocationToken('epubcfi(/6/8!/4:0)'); + const b = normalizeEpubLocationToken(' epubcfi(/6/8[;s=a]!/4:0) '); + expect(a).toBe(b); + }); +}); diff --git a/tests/unit/tts-segment-plan.spec.ts b/tests/unit/tts-segment-plan.spec.ts index d125a30..4efbd21 100644 --- a/tests/unit/tts-segment-plan.spec.ts +++ b/tests/unit/tts-segment-plan.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan'; +import { buildSegmentKey, buildSegmentKeyPrefix, planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan'; test.describe('planCanonicalTtsSegments', () => { test('emits a cross-boundary segment once and assigns it to the source where it starts', () => { @@ -165,3 +165,36 @@ test.describe('planCanonicalTtsSegments', () => { expect(plan.segments[0].ownerSourceKey).toBe('page:1'); }); }); + +test.describe('buildSegmentKeyPrefix / buildSegmentKey contract', () => { + // These keys are the bridge between persistence and the sidebar's merge of + // synth rows with manifest rows. Both sides must produce identical keys for + // the same `(documentId, readerType, text)` triple, or the sidebar will + // show duplicates. + + test('prefix shape is `${documentId}:${readerType}:v1` (or "document" when documentId is falsy)', () => { + expect(buildSegmentKeyPrefix('abc123', 'epub')).toBe('abc123:epub:v1'); + expect(buildSegmentKeyPrefix(null, 'pdf')).toBe('document:pdf:v1'); + expect(buildSegmentKeyPrefix('', 'epub')).toBe('document:epub:v1'); + }); + + test('same prefix + same text → same key (sidebar can match synth to manifest)', () => { + const prefix = buildSegmentKeyPrefix('doc-1', 'epub'); + const k1 = buildSegmentKey(prefix, 'Hello world.'); + const k2 = buildSegmentKey(prefix, 'Hello world.'); + expect(k1).toBe(k2); + }); + + test('normalization makes whitespace + casing differences identity-equivalent', () => { + const prefix = buildSegmentKeyPrefix('doc-1', 'epub'); + const a = buildSegmentKey(prefix, ' Hello world. '); + const b = buildSegmentKey(prefix, 'hello world.'); + expect(a).toBe(b); + }); + + test('different readerType yields different keys for the same text', () => { + const a = buildSegmentKey(buildSegmentKeyPrefix('doc-1', 'epub'), 'Same text.'); + const b = buildSegmentKey(buildSegmentKeyPrefix('doc-1', 'pdf'), 'Same text.'); + expect(a).not.toBe(b); + }); +}); diff --git a/tests/unit/tts-segments-manifest.spec.ts b/tests/unit/tts-segments-manifest.spec.ts index 98b306a..cc20f2b 100644 --- a/tests/unit/tts-segments-manifest.spec.ts +++ b/tests/unit/tts-segments-manifest.spec.ts @@ -86,27 +86,74 @@ test.describe('tts segments manifest helpers', () => { expect(variants[0].status).toBe('completed'); }); - test('sorts segments deterministically by page, location, and segment index', () => { + test('sorts PDF segments deterministically by page and segment index', () => { const rows = [ - { groupKey: 'c', segmentIndex: 4, locator: { page: 2, location: 'z' } }, - { groupKey: 'a', segmentIndex: 1, locator: { page: 1, location: 'b' } }, - { groupKey: 'b', segmentIndex: 0, locator: { page: 1, location: 'a' } }, - { groupKey: 'd', segmentIndex: 2, locator: { page: 1, location: 'a' } }, + { groupKey: 'p2-i4', segmentIndex: 4, locator: { readerType: 'pdf' as const, page: 2 } }, + { groupKey: 'p1-i1', segmentIndex: 1, locator: { readerType: 'pdf' as const, page: 1 } }, + { groupKey: 'p1-i0', segmentIndex: 0, locator: { readerType: 'pdf' as const, page: 1 } }, + { groupKey: 'p1-i2', segmentIndex: 2, locator: { readerType: 'pdf' as const, page: 1 } }, ]; const sorted = rows.sort(compareManifestSegments); - expect(sorted.map((row) => row.groupKey)).toEqual(['b', 'd', 'a', 'c']); + expect(sorted.map((row) => row.groupKey)).toEqual(['p1-i0', 'p1-i1', 'p1-i2', 'p2-i4']); }); - test('sorts EPUB CFI locations naturally instead of lexicographically', () => { + test('sorts EPUB locators by spineIndex then charOffset (numeric, viewport-stable)', () => { + // The order below is intentionally jumbled, with spineIndex 10 placed + // first so that a lexicographic compare on the spine string would put + // "10" before "2". A correct numeric compare must surface spine 2 first. const rows = [ - { groupKey: 'ten', segmentIndex: 0, locator: { location: 'epubcfi(/6/10!/4/2)', readerType: 'epub' as const } }, - { groupKey: 'two-b', segmentIndex: 1, locator: { location: 'epubcfi(/6/2!/4/2)', readerType: 'epub' as const } }, - { groupKey: 'two-a', segmentIndex: 0, locator: { location: 'epubcfi(/6/2!/4/2)', readerType: 'epub' as const } }, + { groupKey: 'spine-10-off-0', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch10.xhtml', spineIndex: 10, charOffset: 0 } }, + { groupKey: 'spine-2-off-200', segmentIndex: 1, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 200 } }, + { groupKey: 'spine-2-off-50', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 50 } }, ]; const sorted = rows.sort(compareManifestSegments); - expect(sorted.map((row) => row.groupKey)).toEqual(['two-a', 'two-b', 'ten']); + expect(sorted.map((row) => row.groupKey)).toEqual(['spine-2-off-50', 'spine-2-off-200', 'spine-10-off-0']); + }); + + test('groups EPUB rows by spine identity, not by raw CFI', () => { + // Two rows in the same spine item but at different char offsets must share + // a groupKey (so the sidebar buckets them together as one chapter), while + // a row in a different spine sits in its own group. + const rows = [ + { groupKey: 'a', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 0, cfi: 'epubcfi(/6/4!/2:0)' } }, + { groupKey: 'b', segmentIndex: 1, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 1024, cfi: 'epubcfi(/6/4!/4:0)' } }, + { groupKey: 'c', segmentIndex: 0, locator: { readerType: 'epub' as const, spineHref: 'OEBPS/ch03.xhtml', spineIndex: 3, charOffset: 0, cfi: 'epubcfi(/6/6!/2:0)' } }, + ]; + + const sorted = rows.sort(compareManifestSegments); + // Recompute groupKey using the production helper to assert the actual + // grouping behavior rather than the test's hand-labeled groupKey. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { locatorGroupKey } = require('../../src/lib/shared/tts-locator'); + const groupKeys = sorted.map((row) => locatorGroupKey(row.locator)); + expect(groupKeys[0]).toBe(groupKeys[1]); // both ch02 + expect(groupKeys[2]).not.toBe(groupKeys[0]); // ch03 is its own group + }); + + test('manifest aggregator distinguishes rows by identity (regression: per-page rows collapsed by chapter bucket)', () => { + // Bug reproduction: previously the server-side aggregator keyed rows by + // `${segmentIndex}|${locatorGroupKey(locator)}`. Because the new EPUB + // groupKey is chapter-coarse, two persisted rows in different *pages* of + // the same chapter that happened to share segmentIndex (which is + // page-relative) collapsed into a single entry — making earlier pages' + // rows visually "disappear" when later pages were generated. + // + // The fix uses `locatorIdentityKey` which includes charOffset for EPUB. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { locatorIdentityKey, locatorGroupKey } = require('../../src/lib/shared/tts-locator'); + + const page1Row0 = { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 0 }; + const page2Row0 = { readerType: 'epub' as const, spineHref: 'OEBPS/ch02.xhtml', spineIndex: 2, charOffset: 2048 }; + + // Same chapter bucket (sidebar grouping should put them under one chapter header). + expect(locatorGroupKey(page1Row0)).toBe(locatorGroupKey(page2Row0)); + // Different storage identity (server aggregator must NOT collapse them). + const segmentIndex = 0; + const k1 = `${segmentIndex}|${locatorIdentityKey(page1Row0)}`; + const k2 = `${segmentIndex}|${locatorIdentityKey(page2Row0)}`; + expect(k1).not.toBe(k2); }); test('encodes and decodes cursors', () => { diff --git a/tests/unit/tts-segments.spec.ts b/tests/unit/tts-segments.spec.ts index ef3e21e..f2175ab 100644 --- a/tests/unit/tts-segments.spec.ts +++ b/tests/unit/tts-segments.spec.ts @@ -95,12 +95,36 @@ test.describe('tts segment helpers', () => { expect(hash).toHaveLength(64); }); - test('normalizes locators and creates fingerprints', () => { - const locator = normalizeLocator({ page: 2.9, location: ' cfi(1) ', readerType: 'epub' }); - expect(locator).toEqual({ page: 2, location: 'cfi(1)', readerType: 'epub' }); + test('normalizes PDF locators and creates fingerprints', () => { + const locator = normalizeLocator({ readerType: 'pdf', page: 2.9 }); + expect(locator).toEqual({ readerType: 'pdf', page: 2 }); expect(locatorFingerprint(locator)).toHaveLength(64); }); + test('normalizes stable EPUB locators and rejects legacy CFI-only drafts', () => { + // Stable shape: passes through with floors and trims applied. + const stable = normalizeLocator({ + readerType: 'epub', + spineHref: ' OEBPS/ch02.xhtml ', + spineIndex: 2.7, + charOffset: 128.4, + cfi: ' epubcfi(/6/4!/4:0) ', + }); + expect(stable).toEqual({ + readerType: 'epub', + spineHref: 'OEBPS/ch02.xhtml', + spineIndex: 2, + charOffset: 128, + cfi: 'epubcfi(/6/4!/4:0)', + }); + expect(locatorFingerprint(stable)).toHaveLength(64); + + // Legacy/draft EPUB shape (CFI in `location` but no spine coords) is + // rejected so we never persist viewport-dependent locators. + const legacy = normalizeLocator({ readerType: 'epub', location: 'epubcfi(/6/4!/4:0)' }); + expect(legacy).toBeNull(); + }); + test('builds proportional alignment preserving order', () => { const alignment = buildProportionalAlignment({ sentence: normalizeSegmentText('Hello world again'), diff --git a/tests/unit/walker-theme.spec.ts b/tests/unit/walker-theme.spec.ts new file mode 100644 index 0000000..c5f5000 --- /dev/null +++ b/tests/unit/walker-theme.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; + +import { buildWalkerThemeRules } from '../../src/lib/client/epub/walker-theme'; + +test.describe('walker theme rules', () => { + test('always includes foreground/background colors', () => { + const rules = buildWalkerThemeRules({ + foreground: 'rgb(1, 2, 3)', + base: 'rgb(9, 8, 7)', + }); + + expect(rules.body.color).toBe('rgb(1, 2, 3)'); + expect(rules.body['background-color']).toBe('rgb(9, 8, 7)'); + }); + + test('includes typography metrics when provided', () => { + const rules = buildWalkerThemeRules({ + foreground: '#111', + base: '#fff', + fontFamily: 'Georgia, serif', + fontSize: '18px', + lineHeight: '1.7', + fontWeight: '400', + letterSpacing: '0.01em', + wordSpacing: '0.03em', + }); + + expect(rules.body['font-family']).toBe('Georgia, serif'); + expect(rules.body['font-size']).toBe('18px'); + expect(rules.body['line-height']).toBe('1.7'); + expect(rules.body['font-weight']).toBe('400'); + expect(rules.body['letter-spacing']).toBe('0.01em'); + expect(rules.body['word-spacing']).toBe('0.03em'); + }); + + test('omits typography keys when missing', () => { + const rules = buildWalkerThemeRules({ + foreground: '#111', + base: '#fff', + fontFamily: '', + lineHeight: '', + }); + + expect('font-family' in rules.body).toBe(false); + expect('line-height' in rules.body).toBe(false); + }); +});