From 9c46e281c27f189012f11e0b9a8e45f9c77a5e15 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sat, 24 Oct 2026 01:06:33 +0000 Subject: [PATCH] books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests --- .prettierignore | 6 + bun.lock | 27 + .../migration.sql | 43 + .../snapshot.json | 13683 ++++++++++++++++ eslint.config.js | 2 +- package.json | 1 + src/hooks.server.ts | 20 +- src/lib/books/BookCover.svelte | 137 + src/lib/books/Stars.svelte | 68 + src/lib/books/progress.test.ts | 56 + src/lib/books/progress.ts | 41 + src/lib/books/reader/EpubReader.svelte | 228 + src/lib/books/reader/PdfReader.svelte | 538 + src/lib/books/reader/types.ts | 89 + src/lib/books/shared.ts | 104 + src/lib/components/ProjectIcon.svelte | 4 +- src/lib/projects.ts | 6 + src/lib/seo.ts | 6 +- src/lib/server/books/files.test.ts | 77 + src/lib/server/books/files.ts | 115 + src/lib/server/books/library.ts | 373 + src/lib/server/books/notes.ts | 84 + src/lib/server/db/schema/books.ts | 49 +- src/lib/server/owner.test.ts | 20 +- src/lib/server/owner.ts | 29 +- src/lib/title.ts | 1 + src/routes/sitemap.xml/+server.ts | 14 +- src/routes/trackers/+page.svelte | 2 +- src/routes/trackers/books/+page.server.ts | 12 + src/routes/trackers/books/+page.svelte | 670 + .../books/[id=integer]/+page.server.ts | 182 + .../trackers/books/[id=integer]/+page.svelte | 683 + .../books/[id=integer]/notes/+server.ts | 14 + .../notes/[noteId=integer]/+server.ts | 21 + .../books/[id=integer]/progress/+server.ts | 21 + .../books/[id=integer]/read/+page.server.ts | 52 + .../books/[id=integer]/read/+page.svelte | 999 ++ src/routes/trackers/books/auth.test.ts | 157 + .../books/covers/[id=integer]/+server.ts | 23 + .../books/files/[id=integer]/+server.ts | 30 + static/vendor/foliate-js/LICENSE | 21 + static/vendor/foliate-js/VERSION | 1 + static/vendor/foliate-js/comic-book.js | 45 + static/vendor/foliate-js/dict.js | 241 + static/vendor/foliate-js/epub.js | 1083 ++ static/vendor/foliate-js/epubcfi.js | 349 + static/vendor/foliate-js/fb2.js | 360 + static/vendor/foliate-js/fixed-layout.js | 319 + static/vendor/foliate-js/footnotes.js | 103 + static/vendor/foliate-js/mobi.js | 1238 ++ static/vendor/foliate-js/opds.js | 289 + static/vendor/foliate-js/overlayer.js | 175 + static/vendor/foliate-js/paginator.js | 1130 ++ static/vendor/foliate-js/pdf.js | 180 + static/vendor/foliate-js/progress.js | 113 + static/vendor/foliate-js/quote-image.js | 86 + static/vendor/foliate-js/search.js | 130 + static/vendor/foliate-js/text-walker.js | 43 + static/vendor/foliate-js/tts.js | 278 + static/vendor/foliate-js/ui/menu.js | 42 + static/vendor/foliate-js/ui/tree.js | 169 + static/vendor/foliate-js/uri-template.js | 52 + static/vendor/foliate-js/vendor/fflate.js | 1 + static/vendor/foliate-js/vendor/zip.js | 1 + static/vendor/foliate-js/view.js | 597 + 65 files changed, 25709 insertions(+), 24 deletions(-) create mode 100644 drizzle/20261024010518_books_reading/migration.sql create mode 100644 drizzle/20261024010518_books_reading/snapshot.json create mode 100644 src/lib/books/BookCover.svelte create mode 100644 src/lib/books/Stars.svelte create mode 100644 src/lib/books/progress.test.ts create mode 100644 src/lib/books/progress.ts create mode 100644 src/lib/books/reader/EpubReader.svelte create mode 100644 src/lib/books/reader/PdfReader.svelte create mode 100644 src/lib/books/reader/types.ts create mode 100644 src/lib/books/shared.ts create mode 100644 src/lib/server/books/files.test.ts create mode 100644 src/lib/server/books/files.ts create mode 100644 src/lib/server/books/library.ts create mode 100644 src/lib/server/books/notes.ts create mode 100644 src/routes/trackers/books/+page.server.ts create mode 100644 src/routes/trackers/books/+page.svelte create mode 100644 src/routes/trackers/books/[id=integer]/+page.server.ts create mode 100644 src/routes/trackers/books/[id=integer]/+page.svelte create mode 100644 src/routes/trackers/books/[id=integer]/notes/+server.ts create mode 100644 src/routes/trackers/books/[id=integer]/notes/[noteId=integer]/+server.ts create mode 100644 src/routes/trackers/books/[id=integer]/progress/+server.ts create mode 100644 src/routes/trackers/books/[id=integer]/read/+page.server.ts create mode 100644 src/routes/trackers/books/[id=integer]/read/+page.svelte create mode 100644 src/routes/trackers/books/auth.test.ts create mode 100644 src/routes/trackers/books/covers/[id=integer]/+server.ts create mode 100644 src/routes/trackers/books/files/[id=integer]/+server.ts create mode 100644 static/vendor/foliate-js/LICENSE create mode 100644 static/vendor/foliate-js/VERSION create mode 100644 static/vendor/foliate-js/comic-book.js create mode 100644 static/vendor/foliate-js/dict.js create mode 100644 static/vendor/foliate-js/epub.js create mode 100644 static/vendor/foliate-js/epubcfi.js create mode 100644 static/vendor/foliate-js/fb2.js create mode 100644 static/vendor/foliate-js/fixed-layout.js create mode 100644 static/vendor/foliate-js/footnotes.js create mode 100644 static/vendor/foliate-js/mobi.js create mode 100644 static/vendor/foliate-js/opds.js create mode 100644 static/vendor/foliate-js/overlayer.js create mode 100644 static/vendor/foliate-js/paginator.js create mode 100644 static/vendor/foliate-js/pdf.js create mode 100644 static/vendor/foliate-js/progress.js create mode 100644 static/vendor/foliate-js/quote-image.js create mode 100644 static/vendor/foliate-js/search.js create mode 100644 static/vendor/foliate-js/text-walker.js create mode 100644 static/vendor/foliate-js/tts.js create mode 100644 static/vendor/foliate-js/ui/menu.js create mode 100644 static/vendor/foliate-js/ui/tree.js create mode 100644 static/vendor/foliate-js/uri-template.js create mode 100644 static/vendor/foliate-js/vendor/fflate.js create mode 100644 static/vendor/foliate-js/vendor/zip.js create mode 100644 static/vendor/foliate-js/view.js diff --git a/.prettierignore b/.prettierignore index 77c5704..450aaba 100755 --- a/.prettierignore +++ b/.prettierignore @@ -19,3 +19,9 @@ data/cod-challenges.json data/game-images.json data/cod-achievement-art.json src/lib/house-tracker/fixtures/ + +# Vendored third-party code +static/vendor/ + +# Generated drizzle snapshots +drizzle/*/snapshot.json diff --git a/bun.lock b/bun.lock index e87b93f..36823f1 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "marked": "^18.0.11", "marked-highlight": "^2.2.4", "mode-watcher": "^1.1.0", + "pdfjs-dist": "^6.4.299", "postgres": "^3.4.9", "topojson-client": "^3.1.0", "world-atlas": "^2.0.2", @@ -326,6 +327,30 @@ "@melt-ui/svelte": ["@melt-ui/svelte@0.76.2", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": ">=3 <5" } }, "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA=="], + "@napi-rs/canvas": ["@napi-rs/canvas@1.0.13", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.13", "@napi-rs/canvas-darwin-arm64": "1.0.13", "@napi-rs/canvas-darwin-x64": "1.0.13", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.13", "@napi-rs/canvas-linux-arm64-gnu": "1.0.13", "@napi-rs/canvas-linux-arm64-musl": "1.0.13", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-musl": "1.0.13", "@napi-rs/canvas-win32-arm64-msvc": "1.0.13", "@napi-rs/canvas-win32-x64-msvc": "1.0.13" } }, "sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.13", "", { "os": "android", "cpu": "arm64" }, "sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.13", "", { "os": "linux", "cpu": "arm" }, "sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.13", "", { "os": "linux", "cpu": "none" }, "sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.13", "", { "os": "win32", "cpu": "x64" }, "sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ=="], + "@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="], "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], @@ -838,6 +863,8 @@ "pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": "bin/pbf" }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="], + "pdfjs-dist": ["pdfjs-dist@6.4.299", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="], + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], "pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="], diff --git a/drizzle/20261024010518_books_reading/migration.sql b/drizzle/20261024010518_books_reading/migration.sql new file mode 100644 index 0000000..2481b0c --- /dev/null +++ b/drizzle/20261024010518_books_reading/migration.sql @@ -0,0 +1,43 @@ +CREATE TYPE "books"."reading_status" AS ENUM('want_to_read', 'reading', 'finished', 'abandoned');--> statement-breakpoint +CREATE TABLE "books"."sessions" ( + "id" serial PRIMARY KEY, + "work_id" integer NOT NULL, + "file_id" integer, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "ended_at" timestamp with time zone, + "start_percent" real, + "end_percent" real, + "start_page" integer, + "end_page" integer, + "pages_read" integer, + "source" text DEFAULT 'reader' NOT NULL, + "note" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "sessions_percent_check" CHECK (("start_percent" is null or "start_percent" between 0 and 100) and ("end_percent" is null or "end_percent" between 0 and 100)), + CONSTRAINT "sessions_end_check" CHECK ("ended_at" is null or "ended_at" >= "started_at") +); +--> statement-breakpoint +ALTER TABLE "books"."progress" ADD COLUMN "status" "books"."reading_status" DEFAULT 'want_to_read'::"books"."reading_status" NOT NULL;--> statement-breakpoint +ALTER TABLE "books"."progress" ADD COLUMN "rating" smallint;--> statement-breakpoint +ALTER TABLE "books"."progress" ADD COLUMN "last_read_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "progress_status_idx" ON "books"."progress" ("status");--> statement-breakpoint +CREATE INDEX "sessions_work_id_idx" ON "books"."sessions" ("work_id");--> statement-breakpoint +CREATE INDEX "sessions_started_at_idx" ON "books"."sessions" ("started_at");--> statement-breakpoint +ALTER TABLE "books"."sessions" ADD CONSTRAINT "sessions_work_id_works_id_fkey" FOREIGN KEY ("work_id") REFERENCES "books"."works"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "books"."sessions" ADD CONSTRAINT "sessions_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "books"."files"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "books"."progress" ADD CONSTRAINT "progress_rating_check" CHECK ("rating" is null or "rating" between 1 and 5); +--> statement-breakpoint +-- Hand-written: updated_at trigger and explicit grants (default privileges from the books migration cover new tables too). +CREATE TRIGGER touch_updated_at BEFORE UPDATE ON books.sessions FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at(); +--> statement-breakpoint +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON books.sessions TO personal_app; + GRANT USAGE, SELECT ON SEQUENCE books.sessions_id_seq TO personal_app; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN + GRANT SELECT ON books.sessions TO personal_ro; + END IF; +END $$; diff --git a/drizzle/20261024010518_books_reading/snapshot.json b/drizzle/20261024010518_books_reading/snapshot.json new file mode 100644 index 0000000..8222d7a --- /dev/null +++ b/drizzle/20261024010518_books_reading/snapshot.json @@ -0,0 +1,13683 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "f239c655-63c8-4807-b44f-b7698330d30a", + "prevIds": [ + "ffad1d0e-51e0-4302-9ab2-022eba184390" + ], + "ddl": [ + { + "name": "animaldex", + "entityType": "schemas" + }, + { + "name": "auth", + "entityType": "schemas" + }, + { + "name": "books", + "entityType": "schemas" + }, + { + "name": "cs", + "entityType": "schemas" + }, + { + "name": "games", + "entityType": "schemas" + }, + { + "name": "health", + "entityType": "schemas" + }, + { + "name": "house", + "entityType": "schemas" + }, + { + "name": "meta", + "entityType": "schemas" + }, + { + "name": "money", + "entityType": "schemas" + }, + { + "name": "ref", + "entityType": "schemas" + }, + { + "name": "site", + "entityType": "schemas" + }, + { + "values": [ + "draft", + "published" + ], + "name": "blog_article_status", + "entityType": "enums", + "schema": "site" + }, + { + "values": [ + "catchable", + "trade_only", + "transfer_only", + "event_only", + "unknown", + "evolve", + "breed" + ], + "name": "availability", + "entityType": "enums", + "schema": "ref" + }, + { + "values": [ + "pending", + "matched", + "low_confidence", + "not_found", + "manual" + ], + "name": "metadata_status", + "entityType": "enums", + "schema": "books" + }, + { + "values": [ + "want_to_read", + "reading", + "finished", + "abandoned" + ], + "name": "reading_status", + "entityType": "enums", + "schema": "books" + }, + { + "isRlsEnabled": false, + "name": "account", + "entityType": "tables", + "schema": "auth" + }, + { + "isRlsEnabled": false, + "name": "session", + "entityType": "tables", + "schema": "auth" + }, + { + "isRlsEnabled": false, + "name": "user", + "entityType": "tables", + "schema": "auth" + }, + { + "isRlsEnabled": false, + "name": "verification", + "entityType": "tables", + "schema": "auth" + }, + { + "isRlsEnabled": false, + "name": "blog_articles", + "entityType": "tables", + "schema": "site" + }, + { + "isRlsEnabled": false, + "name": "tasks", + "entityType": "tables", + "schema": "site" + }, + { + "isRlsEnabled": false, + "name": "task_topics", + "entityType": "tables", + "schema": "site" + }, + { + "isRlsEnabled": false, + "name": "topics", + "entityType": "tables", + "schema": "site" + }, + { + "isRlsEnabled": false, + "name": "catch_em_all_settings", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "pokemon_games", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "pokemon", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "pokemon_encounters", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "pokemon_evolutions", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "pokemon_game_entries", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "pokemon_locations", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "catch_em_all_entries", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "catch_em_all_games", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "tracker_games", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "tracker_items", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "tracker_progress", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "osu_entries", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "pokemon_catches", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "pokemon_dex_numbers", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "pokemon_game_research", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "pokemon_journeys", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "pokemon_learnsets", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "pokemon_milestones", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "pokemon_moves", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "pokemon_research", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "pokemon_seen", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "tracker_challenge_progress", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "tracker_challenges", + "entityType": "tables", + "schema": "ref" + }, + { + "isRlsEnabled": false, + "name": "tracker_ranks", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "tracker_weapon_ranks", + "entityType": "tables", + "schema": "games" + }, + { + "isRlsEnabled": false, + "name": "animals", + "entityType": "tables", + "schema": "animaldex" + }, + { + "isRlsEnabled": false, + "name": "sighting_photos", + "entityType": "tables", + "schema": "animaldex" + }, + { + "isRlsEnabled": false, + "name": "sightings", + "entityType": "tables", + "schema": "animaldex" + }, + { + "isRlsEnabled": false, + "name": "columns", + "entityType": "tables", + "schema": "meta" + }, + { + "isRlsEnabled": false, + "name": "tables", + "entityType": "tables", + "schema": "meta" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "money" + }, + { + "isRlsEnabled": false, + "name": "bank_sessions", + "entityType": "tables", + "schema": "money" + }, + { + "isRlsEnabled": false, + "name": "forecasts", + "entityType": "tables", + "schema": "money" + }, + { + "isRlsEnabled": false, + "name": "insurances", + "entityType": "tables", + "schema": "money" + }, + { + "isRlsEnabled": false, + "name": "projection_settings", + "entityType": "tables", + "schema": "money" + }, + { + "isRlsEnabled": false, + "name": "recurring", + "entityType": "tables", + "schema": "money" + }, + { + "isRlsEnabled": false, + "name": "transactions", + "entityType": "tables", + "schema": "money" + }, + { + "isRlsEnabled": false, + "name": "utility_readings", + "entityType": "tables", + "schema": "money" + }, + { + "isRlsEnabled": false, + "name": "utility_settings", + "entityType": "tables", + "schema": "money" + }, + { + "isRlsEnabled": false, + "name": "wishlist", + "entityType": "tables", + "schema": "money" + }, + { + "isRlsEnabled": false, + "name": "item_dimensions", + "entityType": "tables", + "schema": "cs" + }, + { + "isRlsEnabled": false, + "name": "items", + "entityType": "tables", + "schema": "cs" + }, + { + "isRlsEnabled": false, + "name": "price_observations", + "entityType": "tables", + "schema": "cs" + }, + { + "isRlsEnabled": false, + "name": "price_streams", + "entityType": "tables", + "schema": "cs" + }, + { + "isRlsEnabled": false, + "name": "alert_rules", + "entityType": "tables", + "schema": "house" + }, + { + "isRlsEnabled": false, + "name": "listings", + "entityType": "tables", + "schema": "house" + }, + { + "isRlsEnabled": false, + "name": "price_history", + "entityType": "tables", + "schema": "house" + }, + { + "isRlsEnabled": false, + "name": "searches", + "entityType": "tables", + "schema": "house" + }, + { + "isRlsEnabled": false, + "name": "worker_runs", + "entityType": "tables", + "schema": "house" + }, + { + "isRlsEnabled": false, + "name": "appointments", + "entityType": "tables", + "schema": "health" + }, + { + "isRlsEnabled": false, + "name": "body_measurements", + "entityType": "tables", + "schema": "health" + }, + { + "isRlsEnabled": false, + "name": "medications", + "entityType": "tables", + "schema": "health" + }, + { + "isRlsEnabled": false, + "name": "nutrition_daily", + "entityType": "tables", + "schema": "health" + }, + { + "isRlsEnabled": false, + "name": "sleep", + "entityType": "tables", + "schema": "health" + }, + { + "isRlsEnabled": false, + "name": "vaccinations", + "entityType": "tables", + "schema": "health" + }, + { + "isRlsEnabled": false, + "name": "workouts", + "entityType": "tables", + "schema": "health" + }, + { + "isRlsEnabled": false, + "name": "files", + "entityType": "tables", + "schema": "books" + }, + { + "isRlsEnabled": false, + "name": "notes", + "entityType": "tables", + "schema": "books" + }, + { + "isRlsEnabled": false, + "name": "progress", + "entityType": "tables", + "schema": "books" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "books" + }, + { + "isRlsEnabled": false, + "name": "shelf_works", + "entityType": "tables", + "schema": "books" + }, + { + "isRlsEnabled": false, + "name": "shelves", + "entityType": "tables", + "schema": "books" + }, + { + "isRlsEnabled": false, + "name": "works", + "entityType": "tables", + "schema": "books" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issuer", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_id", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id_token", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_expires_at", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "auth", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "auth", + "table": "session" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "auth", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "auth", + "table": "session" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "auth", + "table": "session" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "auth", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_address", + "entityType": "columns", + "schema": "auth", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_agent", + "entityType": "columns", + "schema": "auth", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "auth", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "auth", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "auth", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'user'", + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "auth", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "auth", + "table": "user" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "email_verified", + "entityType": "columns", + "schema": "auth", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image", + "entityType": "columns", + "schema": "auth", + "table": "user" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "auth", + "table": "user" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "auth", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "auth", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "identifier", + "entityType": "columns", + "schema": "auth", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "auth", + "table": "verification" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "auth", + "table": "verification" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "auth", + "table": "verification" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "auth", + "table": "verification" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "site", + "table": "blog_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "site", + "table": "blog_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "site", + "table": "blog_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "site", + "table": "blog_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "site", + "table": "blog_articles" + }, + { + "type": "blog_article_status", + "typeSchema": "site", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "site", + "table": "blog_articles" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "site", + "table": "blog_articles" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "site", + "table": "blog_articles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "site", + "table": "tasks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "site", + "table": "tasks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "current_points", + "entityType": "columns", + "schema": "site", + "table": "tasks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "total_points", + "entityType": "columns", + "schema": "site", + "table": "tasks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "site", + "table": "tasks" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "site", + "table": "tasks" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "site", + "table": "tasks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "site", + "table": "task_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "task_id", + "entityType": "columns", + "schema": "site", + "table": "task_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic_id", + "entityType": "columns", + "schema": "site", + "table": "task_topics" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "site", + "table": "task_topics" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "site", + "table": "task_topics" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "site", + "table": "topics" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "site", + "table": "topics" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emoji", + "entityType": "columns", + "schema": "site", + "table": "topics" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "site", + "table": "topics" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "site", + "table": "topics" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_games" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_games" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_games" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_games" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_games" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "sort_order", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_games" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "breeding", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_games" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_games" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_games" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "number", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type1", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type2", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "hp", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attack", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "defense", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "special_attack", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "special_defense", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "speed", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "evolves_from", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "evolution_chain", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_baby", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_legendary", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_mythical", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pokemon_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "area", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "min_level", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "max_level", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rarity", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "form", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "conditions", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "note", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pokeapi'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "from_pokemon_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "to_pokemon_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pokeapi'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pokemon_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "type": "availability", + "typeSchema": "ref", + "notNull": true, + "dimensions": 0, + "default": "'catchable'", + "generated": null, + "identity": null, + "name": "availability", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "note", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pokeapi'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "region", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image_key", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image_credit", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pokeapi'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_locations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_entries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pokemon_game_entry_id", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_entries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_entries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_entries" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_games" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_games" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_games" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_games" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "catch_em_all_games" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "tracker_games" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "ref", + "table": "tracker_games" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "franchise", + "entityType": "columns", + "schema": "ref", + "table": "tracker_games" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "ref", + "table": "tracker_games" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "short_name", + "entityType": "columns", + "schema": "ref", + "table": "tracker_games" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "release_year", + "entityType": "columns", + "schema": "ref", + "table": "tracker_games" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "sort_order", + "entityType": "columns", + "schema": "ref", + "table": "tracker_games" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rules", + "entityType": "columns", + "schema": "ref", + "table": "tracker_games" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "ref", + "table": "tracker_games" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "ref", + "table": "tracker_games" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "post_launch", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "sort_order", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "ref", + "table": "tracker_items" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "tracker_progress" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "tracker_progress" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "item_id", + "entityType": "columns", + "schema": "games", + "table": "tracker_progress" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'done'", + "generated": null, + "identity": null, + "name": "milestone", + "entityType": "columns", + "schema": "games", + "table": "tracker_progress" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "tracker_progress" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "tracker_progress" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'osu'", + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "beatmap_url", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "score_url", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pass'", + "generated": null, + "identity": null, + "name": "goal", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'stamina'", + "generated": null, + "identity": null, + "name": "skill", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bpm", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "duration", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "numeric", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accuracy", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "combo", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "misses", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "numeric", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pp", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'NM'", + "generated": null, + "identity": null, + "name": "mods", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "numeric(4,2)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1.00", + "generated": null, + "identity": null, + "name": "rate", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "achieved", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "played_on", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "osu_entries" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pokemon_id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "nickname", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "level", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "moves", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "party_slot", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "shiny", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nature", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ability", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ball", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'caught'", + "generated": null, + "identity": null, + "name": "obtained", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'story'", + "generated": null, + "identity": null, + "name": "stage", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "caught_on", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "pokemon_catches" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_dex_numbers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pokemon_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_dex_numbers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_dex_numbers" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dex", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_dex_numbers" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dex_name", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_dex_numbers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "number", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_dex_numbers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_research" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version_group", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_research" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "regions", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_game_research" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'story'", + "generated": null, + "identity": null, + "name": "stage", + "entityType": "columns", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "completed", + "entityType": "columns", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_learnsets" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pokemon_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_learnsets" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version_group", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_learnsets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "moves", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_learnsets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'story'", + "generated": null, + "identity": null, + "name": "stage", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "done", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_moves" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_moves" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_moves" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_moves" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "power", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_moves" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accuracy", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_moves" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pp", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_moves" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_moves" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_moves" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pokemon_id", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_research" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "facts", + "entityType": "columns", + "schema": "ref", + "table": "pokemon_research" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_seen" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_seen" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_seen" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pokemon_id", + "entityType": "columns", + "schema": "games", + "table": "pokemon_seen" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "pokemon_seen" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "pokemon_seen" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge_id", + "entityType": "columns", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "current", + "entityType": "columns", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "done", + "entityType": "columns", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "requirement", + "entityType": "columns", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reward", + "entityType": "columns", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target", + "entityType": "columns", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_url", + "entityType": "columns", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "tracker_ranks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "tracker_ranks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "game_id", + "entityType": "columns", + "schema": "games", + "table": "tracker_ranks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "level", + "entityType": "columns", + "schema": "games", + "table": "tracker_ranks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "prestige", + "entityType": "columns", + "schema": "games", + "table": "tracker_ranks" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "tracker_ranks" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "tracker_ranks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "item_id", + "entityType": "columns", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "level", + "entityType": "columns", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "prestige", + "entityType": "columns", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "species", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "breed", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animal_name", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ai_breed_suggestion", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ai_breed_confidence", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "submitted_at", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted_at", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "denied_at", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "animaldex", + "table": "animals" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "animaldex", + "table": "sighting_photos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sighting_id", + "entityType": "columns", + "schema": "animaldex", + "table": "sighting_photos" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "r2_key", + "entityType": "columns", + "schema": "animaldex", + "table": "sighting_photos" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "animaldex", + "table": "sighting_photos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "sort_order", + "entityType": "columns", + "schema": "animaldex", + "table": "sighting_photos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "uploaded_at", + "entityType": "columns", + "schema": "animaldex", + "table": "sighting_photos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "animaldex", + "table": "sighting_photos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "animaldex", + "table": "sighting_photos" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animal_id", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reporter_name", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seen_at", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lat", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lng", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "submitted_at", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted_at", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "denied_at", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "animaldex", + "table": "sightings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "columns_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "table_id", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "column_name", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ui_type", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "options", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hidden", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "help_text", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "meta", + "table": "columns" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "tables_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schema_name", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "table_name", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_name", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "picture_url", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "editable", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "has_done", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "default_sort", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "sort_order", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archived_at", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "meta", + "table": "tables" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "opening_balance_cents", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'spendable'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'personal'", + "generated": null, + "identity": null, + "name": "scope", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "owner", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iban", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_uid", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'manual'", + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "money", + "table": "accounts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'enablebanking'", + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aspsp_name", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aspsp_country", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "psu_type", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'authorized'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "valid_until", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "accounts", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_synced_at", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "money", + "table": "bank_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "money", + "table": "forecasts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "money", + "table": "forecasts" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "date", + "entityType": "columns", + "schema": "money", + "table": "forecasts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "money", + "table": "forecasts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount_cents", + "entityType": "columns", + "schema": "money", + "table": "forecasts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "money", + "table": "forecasts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "money", + "table": "forecasts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "money", + "table": "forecasts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "money", + "table": "insurances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "money", + "table": "insurances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "money", + "table": "insurances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "money", + "table": "insurances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverage", + "entityType": "columns", + "schema": "money", + "table": "insurances" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "premium_cents", + "entityType": "columns", + "schema": "money", + "table": "insurances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'monthly'", + "generated": null, + "identity": null, + "name": "cadence", + "entityType": "columns", + "schema": "money", + "table": "insurances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "money", + "table": "insurances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "money", + "table": "insurances" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "investment_account_id", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "100000", + "generated": null, + "identity": null, + "name": "reserve_cents", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "numeric(5,2)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'5.00'", + "generated": null, + "identity": null, + "name": "annual_return_percent", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'excess'", + "generated": null, + "identity": null, + "name": "contribution_mode", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "include_recurring", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "fixed_monthly_cents", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_date", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "money", + "table": "projection_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "money", + "table": "recurring" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "money", + "table": "recurring" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "money", + "table": "recurring" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount_cents", + "entityType": "columns", + "schema": "money", + "table": "recurring" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cadence", + "entityType": "columns", + "schema": "money", + "table": "recurring" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "starts_on", + "entityType": "columns", + "schema": "money", + "table": "recurring" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ends_on", + "entityType": "columns", + "schema": "money", + "table": "recurring" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "money", + "table": "recurring" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "money", + "table": "recurring" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "money", + "table": "recurring" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "date", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount_cents", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_id", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'manual'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "booking_status", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "counterparty", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "raw", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value_date", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "money", + "table": "transactions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "money", + "table": "utility_readings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "money", + "table": "utility_readings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "money", + "table": "utility_readings" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "date", + "entityType": "columns", + "schema": "money", + "table": "utility_readings" + }, + { + "type": "numeric(12,3)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reading", + "entityType": "columns", + "schema": "money", + "table": "utility_readings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "money", + "table": "utility_readings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "money", + "table": "utility_readings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "money", + "table": "utility_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "money", + "table": "utility_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "money", + "table": "utility_settings" + }, + { + "type": "numeric(12,4)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "rate_per_unit", + "entityType": "columns", + "schema": "money", + "table": "utility_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "monthly_advance_cents", + "entityType": "columns", + "schema": "money", + "table": "utility_settings" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "billing_start", + "entityType": "columns", + "schema": "money", + "table": "utility_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "money", + "table": "utility_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "money", + "table": "utility_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "money", + "table": "wishlist" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "money", + "table": "wishlist" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "money", + "table": "wishlist" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "estimated_cents", + "entityType": "columns", + "schema": "money", + "table": "wishlist" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "money", + "table": "wishlist" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "money", + "table": "wishlist" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "money", + "table": "wishlist" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "money", + "table": "wishlist" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "item_id", + "entityType": "columns", + "schema": "cs", + "table": "item_dimensions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "cs", + "table": "item_dimensions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "cs", + "table": "item_dimensions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "cs", + "table": "items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "market_hash_name", + "entityType": "columns", + "schema": "cs", + "table": "items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "cs", + "table": "items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon_url", + "entityType": "columns", + "schema": "cs", + "table": "items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "item_type", + "entityType": "columns", + "schema": "cs", + "table": "items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "first_seen_at", + "entityType": "columns", + "schema": "cs", + "table": "items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "cs", + "table": "items" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "always", + "name": "price_observations_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "cs", + "table": "price_observations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "item_id", + "entityType": "columns", + "schema": "cs", + "table": "price_observations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "cs", + "table": "price_observations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "currency", + "entityType": "columns", + "schema": "cs", + "table": "price_observations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "observed_at", + "entityType": "columns", + "schema": "cs", + "table": "price_observations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "price_cents", + "entityType": "columns", + "schema": "cs", + "table": "price_observations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "volume", + "entityType": "columns", + "schema": "cs", + "table": "price_observations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "cs", + "table": "price_observations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "imported_at", + "entityType": "columns", + "schema": "cs", + "table": "price_observations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "cs", + "table": "price_streams" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "currency", + "entityType": "columns", + "schema": "cs", + "table": "price_streams" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "cs", + "table": "price_streams" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'rent'", + "generated": null, + "identity": null, + "name": "offer", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "min_price", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "max_price", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "min_area", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "min_bedrooms", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "cities", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "property_types", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "house", + "table": "alert_rules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_id", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_id", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "offer", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "property_type", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "street", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "postal_code", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "city", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "city_slug", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "neighbourhood", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "price_eur", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "area_m2", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rooms", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bedrooms", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bathrooms", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "construction_year", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "energy_label", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "interior", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deposit_eur", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "available_from", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contract_type", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "agent", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image_url", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "features", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lat", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lng", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "offered_at", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "first_seen_at", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'slow'", + "generated": null, + "identity": null, + "name": "first_seen_lane", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "detail_fetched_at", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "removed_at", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "alerted_at", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "house", + "table": "listings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "listing_id", + "entityType": "columns", + "schema": "house", + "table": "price_history" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "observed_at", + "entityType": "columns", + "schema": "house", + "table": "price_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "price_eur", + "entityType": "columns", + "schema": "house", + "table": "price_history" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "search_url", + "entityType": "columns", + "schema": "house", + "table": "searches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "house", + "table": "searches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lane", + "entityType": "columns", + "schema": "house", + "table": "searches" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "first_synced_at", + "entityType": "columns", + "schema": "house", + "table": "searches" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_synced_at", + "entityType": "columns", + "schema": "house", + "table": "searches" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "last_found", + "entityType": "columns", + "schema": "house", + "table": "searches" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "always", + "name": "worker_runs_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "house", + "table": "worker_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "house", + "table": "worker_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lane", + "entityType": "columns", + "schema": "house", + "table": "worker_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "house", + "table": "worker_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "house", + "table": "worker_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "http_status", + "entityType": "columns", + "schema": "house", + "table": "worker_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "found", + "entityType": "columns", + "schema": "house", + "table": "worker_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "duration_ms", + "entityType": "columns", + "schema": "house", + "table": "worker_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "house", + "table": "worker_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "house", + "table": "worker_runs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "health", + "table": "appointments" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "date", + "entityType": "columns", + "schema": "health", + "table": "appointments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'appointment'", + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "health", + "table": "appointments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "health", + "table": "appointments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "health", + "table": "appointments" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "health", + "table": "appointments" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "health", + "table": "appointments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "health", + "table": "body_measurements" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "date", + "entityType": "columns", + "schema": "health", + "table": "body_measurements" + }, + { + "type": "numeric(5,2)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "weight_kg", + "entityType": "columns", + "schema": "health", + "table": "body_measurements" + }, + { + "type": "numeric(5,1)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "waist_cm", + "entityType": "columns", + "schema": "health", + "table": "body_measurements" + }, + { + "type": "numeric(4,1)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body_fat_pct", + "entityType": "columns", + "schema": "health", + "table": "body_measurements" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "health", + "table": "body_measurements" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "health", + "table": "body_measurements" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "health", + "table": "body_measurements" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "health", + "table": "medications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "health", + "table": "medications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'medication'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "health", + "table": "medications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dose", + "entityType": "columns", + "schema": "health", + "table": "medications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "frequency", + "entityType": "columns", + "schema": "health", + "table": "medications" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "start_date", + "entityType": "columns", + "schema": "health", + "table": "medications" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stop_date", + "entityType": "columns", + "schema": "health", + "table": "medications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "health", + "table": "medications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "health", + "table": "medications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "health", + "table": "medications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "health", + "table": "nutrition_daily" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "date", + "entityType": "columns", + "schema": "health", + "table": "nutrition_daily" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kcal", + "entityType": "columns", + "schema": "health", + "table": "nutrition_daily" + }, + { + "type": "numeric(5,1)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "protein_g", + "entityType": "columns", + "schema": "health", + "table": "nutrition_daily" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "health", + "table": "nutrition_daily" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "health", + "table": "nutrition_daily" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "health", + "table": "nutrition_daily" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "health", + "table": "sleep" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "date", + "entityType": "columns", + "schema": "health", + "table": "sleep" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bedtime", + "entityType": "columns", + "schema": "health", + "table": "sleep" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "wake_time", + "entityType": "columns", + "schema": "health", + "table": "sleep" + }, + { + "type": "numeric(4,2)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)", + "type": "stored" + }, + "identity": null, + "name": "hours", + "entityType": "columns", + "schema": "health", + "table": "sleep" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "health", + "table": "sleep" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "health", + "table": "sleep" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "health", + "table": "sleep" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "vaccine", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dose_no", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "date", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "batch_lot", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "country", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "health", + "table": "vaccinations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "date", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exercise", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sets", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reps", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "numeric(6,2)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "load_kg", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "numeric(3,1)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rpe", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "health", + "table": "workouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "books", + "table": "files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "work_id", + "entityType": "columns", + "schema": "books", + "table": "files" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "books", + "table": "files" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "format", + "entityType": "columns", + "schema": "books", + "table": "files" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "size_bytes", + "entityType": "columns", + "schema": "books", + "table": "files" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sha256", + "entityType": "columns", + "schema": "books", + "table": "files" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "md5", + "entityType": "columns", + "schema": "books", + "table": "files" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "books", + "table": "files" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "books", + "table": "files" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "books", + "table": "files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "books", + "table": "notes" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "work_id", + "entityType": "columns", + "schema": "books", + "table": "notes" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "file_id", + "entityType": "columns", + "schema": "books", + "table": "notes" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locator", + "entityType": "columns", + "schema": "books", + "table": "notes" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "page", + "entityType": "columns", + "schema": "books", + "table": "notes" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "highlight", + "entityType": "columns", + "schema": "books", + "table": "notes" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "note", + "entityType": "columns", + "schema": "books", + "table": "notes" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "books", + "table": "notes" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "books", + "table": "notes" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "books", + "table": "notes" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "work_id", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "file_id", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locator", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "page", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "real", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "percent", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "reading_status", + "typeSchema": "books", + "notNull": true, + "dimensions": 0, + "default": "'want_to_read'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "smallint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rating", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_read_at", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "books", + "table": "progress" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "work_id", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "file_id", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "real", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "start_percent", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "real", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_percent", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "start_page", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_page", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pages_read", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'reader'", + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "note", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "books", + "table": "sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "shelf_id", + "entityType": "columns", + "schema": "books", + "table": "shelf_works" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "work_id", + "entityType": "columns", + "schema": "books", + "table": "shelf_works" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "sort_order", + "entityType": "columns", + "schema": "books", + "table": "shelf_works" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "added_at", + "entityType": "columns", + "schema": "books", + "table": "shelf_works" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "books", + "table": "shelves" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "books", + "table": "shelves" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "books", + "table": "shelves" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "sort_order", + "entityType": "columns", + "schema": "books", + "table": "shelves" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "books", + "table": "shelves" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "books", + "table": "shelves" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subtitle", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authors", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "isbn10", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "isbn13", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publisher", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "year", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "page_count", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cover_url", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cover_path", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "openlibrary_work_id", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "openlibrary_edition_id", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "google_books_id", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "annas_md5", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "source_refs", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "metadata_status", + "typeSchema": "books", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "metadata_status", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "metadata_source", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "metadata_fetched_at", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "books", + "table": "works" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "account_userId_idx", + "entityType": "indexes", + "schema": "auth", + "table": "account" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "issuer", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "account_issuer_account_id_unique", + "entityType": "indexes", + "schema": "auth", + "table": "account" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "session_userId_idx", + "entityType": "indexes", + "schema": "auth", + "table": "session" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "identifier", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "verification_identifier_idx", + "entityType": "indexes", + "schema": "auth", + "table": "verification" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "pokemon_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "game_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pokemon_encounters_pokemon_id_game_id_index", + "entityType": "indexes", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "location_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pokemon_encounters_location_id_index", + "entityType": "indexes", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "game_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pokemon_evolutions_game_id_index", + "entityType": "indexes", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "game_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pokemon_game_entries_game_id_index", + "entityType": "indexes", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "catch_em_all_entries_user_id_index", + "entityType": "indexes", + "schema": "games", + "table": "catch_em_all_entries" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "catch_em_all_games_user_id_index", + "entityType": "indexes", + "schema": "games", + "table": "catch_em_all_games" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "game_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tracker_items_game_id_index", + "entityType": "indexes", + "schema": "ref", + "table": "tracker_items" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tracker_progress_user_id_index", + "entityType": "indexes", + "schema": "games", + "table": "tracker_progress" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "osu_entries_user_id_kind_index", + "entityType": "indexes", + "schema": "games", + "table": "osu_entries" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "game_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pokemon_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pokemon_catches_user_id_game_id_pokemon_id_index", + "entityType": "indexes", + "schema": "games", + "table": "pokemon_catches" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "game_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pokemon_dex_numbers_game_id_index", + "entityType": "indexes", + "schema": "ref", + "table": "pokemon_dex_numbers" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pokemon_seen_user_id_index", + "entityType": "indexes", + "schema": "games", + "table": "pokemon_seen" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "table_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "columns_table_id_index", + "entityType": "indexes", + "schema": "meta", + "table": "columns" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "accounts_user_id_index", + "entityType": "indexes", + "schema": "money", + "table": "accounts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "bank_sessions_user_id_index", + "entityType": "indexes", + "schema": "money", + "table": "bank_sessions" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forecasts_user_id_date_index", + "entityType": "indexes", + "schema": "money", + "table": "forecasts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "insurances_user_id_index", + "entityType": "indexes", + "schema": "money", + "table": "insurances" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "recurring_user_id_index", + "entityType": "indexes", + "schema": "money", + "table": "recurring" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "transactions_user_id_date_id_index", + "entityType": "indexes", + "schema": "money", + "table": "transactions" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "transactions_account_id_index", + "entityType": "indexes", + "schema": "money", + "table": "transactions" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "utility_readings_user_id_kind_date_index", + "entityType": "indexes", + "schema": "money", + "table": "utility_readings" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "wishlist_user_id_index", + "entityType": "indexes", + "schema": "money", + "table": "wishlist" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "value", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "item_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "item_dimensions_filter_idx", + "entityType": "indexes", + "schema": "cs", + "table": "item_dimensions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "market_hash_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "items_hash_idx", + "entityType": "indexes", + "schema": "cs", + "table": "items" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "items_name_idx", + "entityType": "indexes", + "schema": "cs", + "table": "items" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "item_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "source", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "currency", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "observed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "price_observations_unique_idx", + "entityType": "indexes", + "schema": "cs", + "table": "price_observations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "item_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "source", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "currency", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "observed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "price_observations_lookup_idx", + "entityType": "indexes", + "schema": "cs", + "table": "price_observations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "observed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "price_observations_time_idx", + "entityType": "indexes", + "schema": "cs", + "table": "price_observations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "source_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "listings_source_idx", + "entityType": "indexes", + "schema": "house", + "table": "listings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "offer", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "first_seen_at", + "isExpression": false, + "asc": false, + "nullsFirst": true, + "opclass": null + } + ], + "isUnique": false, + "where": "removed_at IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "listings_active_idx", + "entityType": "indexes", + "schema": "house", + "table": "listings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "offer", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "city_slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "listings_city_idx", + "entityType": "indexes", + "schema": "house", + "table": "listings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "first_seen_at", + "isExpression": false, + "asc": false, + "nullsFirst": true, + "opclass": null + } + ], + "isUnique": false, + "where": "detail_fetched_at IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "listings_details_idx", + "entityType": "indexes", + "schema": "house", + "table": "listings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": true, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "worker_runs_created_idx", + "entityType": "indexes", + "schema": "house", + "table": "worker_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "appointments_date_idx", + "entityType": "indexes", + "schema": "health", + "table": "appointments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "body_measurements_date_idx", + "entityType": "indexes", + "schema": "health", + "table": "body_measurements" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "start_date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "medications_start_date_idx", + "entityType": "indexes", + "schema": "health", + "table": "medications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "stop_date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "medications_stop_date_idx", + "entityType": "indexes", + "schema": "health", + "table": "medications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vaccinations_date_idx", + "entityType": "indexes", + "schema": "health", + "table": "vaccinations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workouts_date_idx", + "entityType": "indexes", + "schema": "health", + "table": "workouts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "session_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workouts_session_idx", + "entityType": "indexes", + "schema": "health", + "table": "workouts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "path", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "files_path_key", + "entityType": "indexes", + "schema": "books", + "table": "files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "sha256", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "files_sha256_key", + "entityType": "indexes", + "schema": "books", + "table": "files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "work_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "files_work_id_idx", + "entityType": "indexes", + "schema": "books", + "table": "files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "work_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notes_work_id_idx", + "entityType": "indexes", + "schema": "books", + "table": "notes" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "work_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "progress_work_id_key", + "entityType": "indexes", + "schema": "books", + "table": "progress" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "progress_status_idx", + "entityType": "indexes", + "schema": "books", + "table": "progress" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "work_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_work_id_idx", + "entityType": "indexes", + "schema": "books", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "started_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_started_at_idx", + "entityType": "indexes", + "schema": "books", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "work_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "shelf_works_work_id_idx", + "entityType": "indexes", + "schema": "books", + "table": "shelf_works" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "shelves_slug_key", + "entityType": "indexes", + "schema": "books", + "table": "shelves" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isbn13", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"isbn13\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "works_isbn13_key", + "entityType": "indexes", + "schema": "books", + "table": "works" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lower(\"title\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "works_title_idx", + "entityType": "indexes", + "schema": "books", + "table": "works" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "account_user_id_user_id_fkey", + "entityType": "fks", + "schema": "auth", + "table": "account" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "session_user_id_user_id_fkey", + "entityType": "fks", + "schema": "auth", + "table": "session" + }, + { + "nameExplicit": false, + "columns": [ + "task_id" + ], + "schemaTo": "site", + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "task_topics_task_id_tasks_id_fkey", + "entityType": "fks", + "schema": "site", + "table": "task_topics" + }, + { + "nameExplicit": false, + "columns": [ + "topic_id" + ], + "schemaTo": "site", + "tableTo": "topics", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "task_topics_topic_id_topics_id_fkey", + "entityType": "fks", + "schema": "site", + "table": "task_topics" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "catch_em_all_settings_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "catch_em_all_settings" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_encounters_pokemon_id_pokemon_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_encounters_game_id_pokemon_games_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "nameExplicit": false, + "columns": [ + "location_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_locations", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_encounters_location_id_pokemon_locations_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_encounters" + }, + { + "nameExplicit": false, + "columns": [ + "from_pokemon_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_evolutions_from_pokemon_id_pokemon_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "nameExplicit": false, + "columns": [ + "to_pokemon_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_evolutions_to_pokemon_id_pokemon_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_evolutions_game_id_pokemon_games_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_game_entries_pokemon_id_pokemon_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_game_entries_game_id_pokemon_games_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_game_entry_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_game_entries", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "catch_em_all_entries_HeABXvGNiB6D_fkey", + "entityType": "fks", + "schema": "games", + "table": "catch_em_all_entries" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "catch_em_all_entries_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "catch_em_all_entries" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "catch_em_all_games_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "catch_em_all_games" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "catch_em_all_games_game_id_pokemon_games_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "catch_em_all_games" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "tracker_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tracker_items_game_id_tracker_games_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "tracker_items" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tracker_progress_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "tracker_progress" + }, + { + "nameExplicit": false, + "columns": [ + "item_id" + ], + "schemaTo": "ref", + "tableTo": "tracker_items", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tracker_progress_item_id_tracker_items_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "tracker_progress" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "osu_entries_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "osu_entries" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_catches_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "pokemon_catches" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_catches_game_id_pokemon_games_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "pokemon_catches" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_catches_pokemon_id_pokemon_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "pokemon_catches" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_dex_numbers_pokemon_id_pokemon_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_dex_numbers" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_dex_numbers_game_id_pokemon_games_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_dex_numbers" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_game_research_game_id_pokemon_games_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_game_research" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_journeys_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_journeys_game_id_pokemon_games_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_learnsets_pokemon_id_pokemon_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_learnsets" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_milestones_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_milestones_game_id_pokemon_games_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_research_pokemon_id_pokemon_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "pokemon_research" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_seen_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "pokemon_seen" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_seen_game_id_pokemon_games_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "pokemon_seen" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_id" + ], + "schemaTo": "ref", + "tableTo": "pokemon", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pokemon_seen_pokemon_id_pokemon_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "pokemon_seen" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tracker_challenge_progress_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "nameExplicit": false, + "columns": [ + "challenge_id" + ], + "schemaTo": "ref", + "tableTo": "tracker_challenges", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tracker_challenge_progress_HtzyFtOyUAhk_fkey", + "entityType": "fks", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "tracker_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tracker_challenges_game_id_tracker_games_id_fkey", + "entityType": "fks", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tracker_ranks_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "tracker_ranks" + }, + { + "nameExplicit": false, + "columns": [ + "game_id" + ], + "schemaTo": "ref", + "tableTo": "tracker_games", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tracker_ranks_game_id_tracker_games_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "tracker_ranks" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tracker_weapon_ranks_user_id_user_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "nameExplicit": false, + "columns": [ + "item_id" + ], + "schemaTo": "ref", + "tableTo": "tracker_items", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tracker_weapon_ranks_item_id_tracker_items_id_fkey", + "entityType": "fks", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "nameExplicit": false, + "columns": [ + "sighting_id" + ], + "schemaTo": "animaldex", + "tableTo": "sightings", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sighting_photos_sighting_id_sightings_id_fkey", + "entityType": "fks", + "schema": "animaldex", + "table": "sighting_photos" + }, + { + "nameExplicit": false, + "columns": [ + "animal_id" + ], + "schemaTo": "animaldex", + "tableTo": "animals", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sightings_animal_id_animals_id_fkey", + "entityType": "fks", + "schema": "animaldex", + "table": "sightings" + }, + { + "nameExplicit": false, + "columns": [ + "table_id" + ], + "schemaTo": "meta", + "tableTo": "tables", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "columns_table_id_tables_id_fkey", + "entityType": "fks", + "schema": "meta", + "table": "columns" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "accounts_user_id_user_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "accounts" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "bank_sessions_user_id_user_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "bank_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "forecasts_user_id_user_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "forecasts" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "money", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "forecasts_account_id_accounts_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "forecasts" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "insurances_user_id_user_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "insurances" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "projection_settings_user_id_user_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "projection_settings" + }, + { + "nameExplicit": false, + "columns": [ + "investment_account_id" + ], + "schemaTo": "money", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "projection_settings_investment_account_id_accounts_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "projection_settings" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "recurring_user_id_user_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "recurring" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "money", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "recurring_account_id_accounts_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "recurring" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "transactions_user_id_user_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "transactions" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "money", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "transactions_account_id_accounts_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "transactions" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "utility_readings_user_id_user_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "utility_readings" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "utility_settings_user_id_user_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "utility_settings" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "auth", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "wishlist_user_id_user_id_fkey", + "entityType": "fks", + "schema": "money", + "table": "wishlist" + }, + { + "nameExplicit": false, + "columns": [ + "item_id" + ], + "schemaTo": "cs", + "tableTo": "items", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "item_dimensions_item_id_items_id_fkey", + "entityType": "fks", + "schema": "cs", + "table": "item_dimensions" + }, + { + "nameExplicit": false, + "columns": [ + "item_id" + ], + "schemaTo": "cs", + "tableTo": "items", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "price_observations_item_id_items_id_fkey", + "entityType": "fks", + "schema": "cs", + "table": "price_observations" + }, + { + "nameExplicit": false, + "columns": [ + "listing_id" + ], + "schemaTo": "house", + "tableTo": "listings", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "price_history_listing_id_listings_id_fkey", + "entityType": "fks", + "schema": "house", + "table": "price_history" + }, + { + "nameExplicit": false, + "columns": [ + "work_id" + ], + "schemaTo": "books", + "tableTo": "works", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "files_work_id_works_id_fkey", + "entityType": "fks", + "schema": "books", + "table": "files" + }, + { + "nameExplicit": false, + "columns": [ + "work_id" + ], + "schemaTo": "books", + "tableTo": "works", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notes_work_id_works_id_fkey", + "entityType": "fks", + "schema": "books", + "table": "notes" + }, + { + "nameExplicit": false, + "columns": [ + "file_id" + ], + "schemaTo": "books", + "tableTo": "files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "notes_file_id_files_id_fkey", + "entityType": "fks", + "schema": "books", + "table": "notes" + }, + { + "nameExplicit": false, + "columns": [ + "work_id" + ], + "schemaTo": "books", + "tableTo": "works", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "progress_work_id_works_id_fkey", + "entityType": "fks", + "schema": "books", + "table": "progress" + }, + { + "nameExplicit": false, + "columns": [ + "file_id" + ], + "schemaTo": "books", + "tableTo": "files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "progress_file_id_files_id_fkey", + "entityType": "fks", + "schema": "books", + "table": "progress" + }, + { + "nameExplicit": false, + "columns": [ + "work_id" + ], + "schemaTo": "books", + "tableTo": "works", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_work_id_works_id_fkey", + "entityType": "fks", + "schema": "books", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "file_id" + ], + "schemaTo": "books", + "tableTo": "files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "sessions_file_id_files_id_fkey", + "entityType": "fks", + "schema": "books", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "shelf_id" + ], + "schemaTo": "books", + "tableTo": "shelves", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "shelf_works_shelf_id_shelves_id_fkey", + "entityType": "fks", + "schema": "books", + "table": "shelf_works" + }, + { + "nameExplicit": false, + "columns": [ + "work_id" + ], + "schemaTo": "books", + "tableTo": "works", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "shelf_works_work_id_works_id_fkey", + "entityType": "fks", + "schema": "books", + "table": "shelf_works" + }, + { + "columns": [ + "item_id", + "key" + ], + "nameExplicit": true, + "name": "item_dimensions_pkey", + "entityType": "pks", + "schema": "cs", + "table": "item_dimensions" + }, + { + "columns": [ + "source", + "currency", + "kind" + ], + "nameExplicit": true, + "name": "price_streams_pkey", + "entityType": "pks", + "schema": "cs", + "table": "price_streams" + }, + { + "columns": [ + "listing_id", + "observed_at" + ], + "nameExplicit": true, + "name": "price_history_pkey", + "entityType": "pks", + "schema": "house", + "table": "price_history" + }, + { + "columns": [ + "shelf_id", + "work_id" + ], + "nameExplicit": false, + "name": "shelf_works_pkey", + "entityType": "pks", + "schema": "books", + "table": "shelf_works" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pkey", + "schema": "auth", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pkey", + "schema": "auth", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pkey", + "schema": "auth", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pkey", + "schema": "auth", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_articles_pkey", + "schema": "site", + "table": "blog_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tasks_pkey", + "schema": "site", + "table": "tasks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "task_topics_pkey", + "schema": "site", + "table": "task_topics", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "topics_pkey", + "schema": "site", + "table": "topics", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "catch_em_all_settings_pkey", + "schema": "games", + "table": "catch_em_all_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_games_pkey", + "schema": "ref", + "table": "pokemon_games", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_pkey", + "schema": "ref", + "table": "pokemon", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_encounters_pkey", + "schema": "ref", + "table": "pokemon_encounters", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_evolutions_pkey", + "schema": "ref", + "table": "pokemon_evolutions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_game_entries_pkey", + "schema": "ref", + "table": "pokemon_game_entries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_locations_pkey", + "schema": "ref", + "table": "pokemon_locations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "catch_em_all_entries_pkey", + "schema": "games", + "table": "catch_em_all_entries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "catch_em_all_games_pkey", + "schema": "games", + "table": "catch_em_all_games", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tracker_games_pkey", + "schema": "ref", + "table": "tracker_games", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tracker_items_pkey", + "schema": "ref", + "table": "tracker_items", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tracker_progress_pkey", + "schema": "games", + "table": "tracker_progress", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "osu_entries_pkey", + "schema": "games", + "table": "osu_entries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_catches_pkey", + "schema": "games", + "table": "pokemon_catches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_dex_numbers_pkey", + "schema": "ref", + "table": "pokemon_dex_numbers", + "entityType": "pks" + }, + { + "columns": [ + "game_id" + ], + "nameExplicit": false, + "name": "pokemon_game_research_pkey", + "schema": "ref", + "table": "pokemon_game_research", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_journeys_pkey", + "schema": "games", + "table": "pokemon_journeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_learnsets_pkey", + "schema": "ref", + "table": "pokemon_learnsets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_milestones_pkey", + "schema": "games", + "table": "pokemon_milestones", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_moves_pkey", + "schema": "ref", + "table": "pokemon_moves", + "entityType": "pks" + }, + { + "columns": [ + "pokemon_id" + ], + "nameExplicit": false, + "name": "pokemon_research_pkey", + "schema": "ref", + "table": "pokemon_research", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pokemon_seen_pkey", + "schema": "games", + "table": "pokemon_seen", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tracker_challenge_progress_pkey", + "schema": "games", + "table": "tracker_challenge_progress", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tracker_challenges_pkey", + "schema": "ref", + "table": "tracker_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tracker_ranks_pkey", + "schema": "games", + "table": "tracker_ranks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tracker_weapon_ranks_pkey", + "schema": "games", + "table": "tracker_weapon_ranks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "animals_pkey", + "schema": "animaldex", + "table": "animals", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sighting_photos_pkey", + "schema": "animaldex", + "table": "sighting_photos", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sightings_pkey", + "schema": "animaldex", + "table": "sightings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "columns_pkey", + "schema": "meta", + "table": "columns", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tables_pkey", + "schema": "meta", + "table": "tables", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "accounts_pkey", + "schema": "money", + "table": "accounts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bank_sessions_pkey", + "schema": "money", + "table": "bank_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forecasts_pkey", + "schema": "money", + "table": "forecasts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "insurances_pkey", + "schema": "money", + "table": "insurances", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "projection_settings_pkey", + "schema": "money", + "table": "projection_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "recurring_pkey", + "schema": "money", + "table": "recurring", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "transactions_pkey", + "schema": "money", + "table": "transactions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "utility_readings_pkey", + "schema": "money", + "table": "utility_readings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "utility_settings_pkey", + "schema": "money", + "table": "utility_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "wishlist_pkey", + "schema": "money", + "table": "wishlist", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "items_pkey", + "schema": "cs", + "table": "items", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "price_observations_pkey", + "schema": "cs", + "table": "price_observations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "alert_rules_pkey", + "schema": "house", + "table": "alert_rules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "listings_pkey", + "schema": "house", + "table": "listings", + "entityType": "pks" + }, + { + "columns": [ + "search_url" + ], + "nameExplicit": false, + "name": "searches_pkey", + "schema": "house", + "table": "searches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "worker_runs_pkey", + "schema": "house", + "table": "worker_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "appointments_pkey", + "schema": "health", + "table": "appointments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "body_measurements_pkey", + "schema": "health", + "table": "body_measurements", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "medications_pkey", + "schema": "health", + "table": "medications", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "nutrition_daily_pkey", + "schema": "health", + "table": "nutrition_daily", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sleep_pkey", + "schema": "health", + "table": "sleep", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "vaccinations_pkey", + "schema": "health", + "table": "vaccinations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workouts_pkey", + "schema": "health", + "table": "workouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "files_pkey", + "schema": "books", + "table": "files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notes_pkey", + "schema": "books", + "table": "notes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "progress_pkey", + "schema": "books", + "table": "progress", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "books", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "shelves_pkey", + "schema": "books", + "table": "shelves", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "works_pkey", + "schema": "books", + "table": "works", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "blog_articles_slug_unique", + "entityType": "uniques", + "schema": "site", + "table": "blog_articles" + }, + { + "nameExplicit": false, + "columns": [ + "task_id", + "topic_id" + ], + "nullsNotDistinct": false, + "name": "task_topics_task_id_topic_id_unique", + "entityType": "uniques", + "schema": "site", + "table": "task_topics" + }, + { + "nameExplicit": false, + "columns": [ + "from_pokemon_id", + "to_pokemon_id", + "game_id", + "method" + ], + "nullsNotDistinct": false, + "name": "pokemon_evolutions_from_pokemon_id_to_pokemon_id_game_id_method_unique", + "entityType": "uniques", + "schema": "ref", + "table": "pokemon_evolutions" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_id", + "game_id" + ], + "nullsNotDistinct": false, + "name": "pokemon_game_entries_pokemon_id_game_id_unique", + "entityType": "uniques", + "schema": "ref", + "table": "pokemon_game_entries" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_game_entry_id", + "user_id" + ], + "nullsNotDistinct": false, + "name": "catch_em_all_entries_pokemon_game_entry_id_user_id_unique", + "entityType": "uniques", + "schema": "games", + "table": "catch_em_all_entries" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "game_id" + ], + "nullsNotDistinct": false, + "name": "catch_em_all_games_user_id_game_id_unique", + "entityType": "uniques", + "schema": "games", + "table": "catch_em_all_games" + }, + { + "nameExplicit": false, + "columns": [ + "game_id", + "key" + ], + "nullsNotDistinct": false, + "name": "tracker_items_game_id_key_unique", + "entityType": "uniques", + "schema": "ref", + "table": "tracker_items" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "item_id", + "milestone" + ], + "nullsNotDistinct": false, + "name": "tracker_progress_user_id_item_id_milestone_unique", + "entityType": "uniques", + "schema": "games", + "table": "tracker_progress" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "game_id", + "party_slot" + ], + "nullsNotDistinct": false, + "name": "pokemon_catches_user_id_game_id_party_slot_unique", + "entityType": "uniques", + "schema": "games", + "table": "pokemon_catches" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_id", + "game_id", + "dex" + ], + "nullsNotDistinct": false, + "name": "pokemon_dex_numbers_pokemon_id_game_id_dex_unique", + "entityType": "uniques", + "schema": "ref", + "table": "pokemon_dex_numbers" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "game_id" + ], + "nullsNotDistinct": false, + "name": "pokemon_journeys_user_id_game_id_unique", + "entityType": "uniques", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "nameExplicit": false, + "columns": [ + "pokemon_id", + "version_group" + ], + "nullsNotDistinct": false, + "name": "pokemon_learnsets_pokemon_id_version_group_unique", + "entityType": "uniques", + "schema": "ref", + "table": "pokemon_learnsets" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "game_id", + "key" + ], + "nullsNotDistinct": false, + "name": "pokemon_milestones_user_id_game_id_key_unique", + "entityType": "uniques", + "schema": "games", + "table": "pokemon_milestones" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "game_id", + "pokemon_id" + ], + "nullsNotDistinct": false, + "name": "pokemon_seen_user_id_game_id_pokemon_id_unique", + "entityType": "uniques", + "schema": "games", + "table": "pokemon_seen" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "challenge_id" + ], + "nullsNotDistinct": false, + "name": "tracker_challenge_progress_user_id_challenge_id_unique", + "entityType": "uniques", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "nameExplicit": false, + "columns": [ + "game_id", + "key" + ], + "nullsNotDistinct": false, + "name": "tracker_challenges_game_id_key_unique", + "entityType": "uniques", + "schema": "ref", + "table": "tracker_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "game_id" + ], + "nullsNotDistinct": false, + "name": "tracker_ranks_user_id_game_id_unique", + "entityType": "uniques", + "schema": "games", + "table": "tracker_ranks" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "item_id" + ], + "nullsNotDistinct": false, + "name": "tracker_weapon_ranks_user_id_item_id_unique", + "entityType": "uniques", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "nameExplicit": true, + "columns": [ + "table_id", + "column_name" + ], + "nullsNotDistinct": false, + "name": "columns_table_id_column_name_key", + "entityType": "uniques", + "schema": "meta", + "table": "columns" + }, + { + "nameExplicit": true, + "columns": [ + "schema_name", + "table_name" + ], + "nullsNotDistinct": false, + "name": "tables_schema_name_table_name_key", + "entityType": "uniques", + "schema": "meta", + "table": "tables" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "name" + ], + "nullsNotDistinct": false, + "name": "accounts_user_id_name_unique", + "entityType": "uniques", + "schema": "money", + "table": "accounts" + }, + { + "nameExplicit": true, + "columns": [ + "external_uid" + ], + "nullsNotDistinct": false, + "name": "accounts_external_uid_key", + "entityType": "uniques", + "schema": "money", + "table": "accounts" + }, + { + "nameExplicit": true, + "columns": [ + "session_id" + ], + "nullsNotDistinct": false, + "name": "bank_sessions_session_id_key", + "entityType": "uniques", + "schema": "money", + "table": "bank_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "nullsNotDistinct": false, + "name": "projection_settings_user_id_unique", + "entityType": "uniques", + "schema": "money", + "table": "projection_settings" + }, + { + "nameExplicit": true, + "columns": [ + "account_id", + "external_id" + ], + "nullsNotDistinct": false, + "name": "transactions_account_external_id_key", + "entityType": "uniques", + "schema": "money", + "table": "transactions" + }, + { + "nameExplicit": false, + "columns": [ + "user_id", + "kind" + ], + "nullsNotDistinct": false, + "name": "utility_settings_user_id_kind_unique", + "entityType": "uniques", + "schema": "money", + "table": "utility_settings" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "session_token_key", + "schema": "auth", + "table": "session", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "user_email_key", + "schema": "auth", + "table": "user", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "nullsNotDistinct": false, + "name": "catch_em_all_settings_user_id_key", + "schema": "games", + "table": "catch_em_all_settings", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "source_id" + ], + "nullsNotDistinct": false, + "name": "pokemon_games_source_id_key", + "schema": "ref", + "table": "pokemon_games", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "pokemon_games_slug_key", + "schema": "ref", + "table": "pokemon_games", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "number" + ], + "nullsNotDistinct": false, + "name": "pokemon_number_key", + "schema": "ref", + "table": "pokemon", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "source_id" + ], + "nullsNotDistinct": false, + "name": "pokemon_locations_source_id_key", + "schema": "ref", + "table": "pokemon_locations", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "tracker_games_slug_key", + "schema": "ref", + "table": "tracker_games", + "entityType": "uniques" + }, + { + "nameExplicit": true, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "tables_slug_key", + "schema": "meta", + "table": "tables", + "entityType": "uniques" + }, + { + "nameExplicit": true, + "columns": [ + "date" + ], + "nullsNotDistinct": false, + "name": "nutrition_daily_date_key", + "schema": "health", + "table": "nutrition_daily", + "entityType": "uniques" + }, + { + "nameExplicit": true, + "columns": [ + "date" + ], + "nullsNotDistinct": false, + "name": "sleep_date_key", + "schema": "health", + "table": "sleep", + "entityType": "uniques" + }, + { + "value": "\"kind\" IN ('score','goal','training')", + "name": "osu_entries_kind_check", + "entityType": "checks", + "schema": "games", + "table": "osu_entries" + }, + { + "value": "\"level\" between 1 and 100", + "name": "pokemon_catches_level_check", + "entityType": "checks", + "schema": "games", + "table": "pokemon_catches" + }, + { + "value": "\"party_slot\" between 1 and 6", + "name": "pokemon_catches_party_slot_check", + "entityType": "checks", + "schema": "games", + "table": "pokemon_catches" + }, + { + "value": "cardinality(\"moves\") <= 4", + "name": "pokemon_catches_moves_check", + "entityType": "checks", + "schema": "games", + "table": "pokemon_catches" + }, + { + "value": "\"stage\" IN ('story','postgame','collection')", + "name": "pokemon_catches_stage_check", + "entityType": "checks", + "schema": "games", + "table": "pokemon_catches" + }, + { + "value": "\"stage\" IN ('story','postgame','collection')", + "name": "pokemon_journeys_stage_check", + "entityType": "checks", + "schema": "games", + "table": "pokemon_journeys" + }, + { + "value": "\"current\" >= 0", + "name": "tracker_challenge_progress_current_check", + "entityType": "checks", + "schema": "games", + "table": "tracker_challenge_progress" + }, + { + "value": "\"level\" between 1 and 10000", + "name": "tracker_ranks_level_check", + "entityType": "checks", + "schema": "games", + "table": "tracker_ranks" + }, + { + "value": "\"prestige\" between 0 and 1000", + "name": "tracker_ranks_prestige_check", + "entityType": "checks", + "schema": "games", + "table": "tracker_ranks" + }, + { + "value": "\"level\" between 1 and 10000", + "name": "tracker_weapon_ranks_level_check", + "entityType": "checks", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "value": "\"prestige\" between 0 and 1000", + "name": "tracker_weapon_ranks_prestige_check", + "entityType": "checks", + "schema": "games", + "table": "tracker_weapon_ranks" + }, + { + "value": "ui_type IN ('text','longtext','url','image','number','cents','date','datetime','bool','select','multiselect','fk','json')", + "name": "columns_ui_type_check", + "entityType": "checks", + "schema": "meta", + "table": "columns" + }, + { + "value": "slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'", + "name": "tables_slug_check", + "entityType": "checks", + "schema": "meta", + "table": "tables" + }, + { + "value": "kind IN ('list','core','ref')", + "name": "tables_kind_check", + "entityType": "checks", + "schema": "meta", + "table": "tables" + }, + { + "value": "kind IN ('spendable','investment')", + "name": "accounts_kind_check", + "entityType": "checks", + "schema": "money", + "table": "accounts" + }, + { + "value": "scope IN ('personal','joint')", + "name": "accounts_scope_check", + "entityType": "checks", + "schema": "money", + "table": "accounts" + }, + { + "value": "provider IN ('manual','enablebanking')", + "name": "accounts_provider_check", + "entityType": "checks", + "schema": "money", + "table": "accounts" + }, + { + "value": "status IN ('authorized','expired','revoked','error')", + "name": "bank_sessions_status_check", + "entityType": "checks", + "schema": "money", + "table": "bank_sessions" + }, + { + "value": "cadence IN ('weekly','monthly','yearly')", + "name": "insurances_cadence_check", + "entityType": "checks", + "schema": "money", + "table": "insurances" + }, + { + "value": "contribution_mode IN ('excess','fixed')", + "name": "projection_settings_contribution_mode_check", + "entityType": "checks", + "schema": "money", + "table": "projection_settings" + }, + { + "value": "cadence IN ('weekly','monthly','yearly')", + "name": "recurring_cadence_check", + "entityType": "checks", + "schema": "money", + "table": "recurring" + }, + { + "value": "source IN ('manual','csv','enablebanking')", + "name": "transactions_source_check", + "entityType": "checks", + "schema": "money", + "table": "transactions" + }, + { + "value": "kind IN ('electricity','gas','water')", + "name": "utility_readings_kind_check", + "entityType": "checks", + "schema": "money", + "table": "utility_readings" + }, + { + "value": "kind IN ('electricity','gas','water')", + "name": "utility_settings_kind_check", + "entityType": "checks", + "schema": "money", + "table": "utility_settings" + }, + { + "value": "offer IN ('rent', 'buy')", + "name": "alert_rules_offer_check", + "entityType": "checks", + "schema": "house", + "table": "alert_rules" + }, + { + "value": "offer IN ('rent', 'buy')", + "name": "listings_offer_check", + "entityType": "checks", + "schema": "house", + "table": "listings" + }, + { + "value": "price_eur IS NULL OR price_eur >= 0", + "name": "listings_price_eur_check", + "entityType": "checks", + "schema": "house", + "table": "listings" + }, + { + "value": "\"type\" IN ('appointment','checkup','condition','test','procedure','other')", + "name": "appointments_type_check", + "entityType": "checks", + "schema": "health", + "table": "appointments" + }, + { + "value": "\"date\" >= DATE '1900-01-01'", + "name": "appointments_date_check", + "entityType": "checks", + "schema": "health", + "table": "appointments" + }, + { + "value": "\"weight_kg\" IS NOT NULL OR \"waist_cm\" IS NOT NULL OR \"body_fat_pct\" IS NOT NULL", + "name": "body_measurements_any_check", + "entityType": "checks", + "schema": "health", + "table": "body_measurements" + }, + { + "value": "\"weight_kg\" IS NULL OR \"weight_kg\" BETWEEN 20 AND 400", + "name": "body_measurements_weight_check", + "entityType": "checks", + "schema": "health", + "table": "body_measurements" + }, + { + "value": "\"waist_cm\" IS NULL OR \"waist_cm\" BETWEEN 30 AND 250", + "name": "body_measurements_waist_check", + "entityType": "checks", + "schema": "health", + "table": "body_measurements" + }, + { + "value": "\"body_fat_pct\" IS NULL OR \"body_fat_pct\" BETWEEN 1 AND 75", + "name": "body_measurements_fat_check", + "entityType": "checks", + "schema": "health", + "table": "body_measurements" + }, + { + "value": "\"date\" >= DATE '1900-01-01'", + "name": "body_measurements_date_check", + "entityType": "checks", + "schema": "health", + "table": "body_measurements" + }, + { + "value": "length(btrim(\"name\")) > 0", + "name": "medications_name_check", + "entityType": "checks", + "schema": "health", + "table": "medications" + }, + { + "value": "\"kind\" IN ('medication','supplement')", + "name": "medications_kind_check", + "entityType": "checks", + "schema": "health", + "table": "medications" + }, + { + "value": "\"stop_date\" IS NULL OR \"start_date\" IS NULL OR \"stop_date\" >= \"start_date\"", + "name": "medications_dates_check", + "entityType": "checks", + "schema": "health", + "table": "medications" + }, + { + "value": "\"kcal\" IS NULL OR \"kcal\" BETWEEN 0 AND 20000", + "name": "nutrition_daily_kcal_check", + "entityType": "checks", + "schema": "health", + "table": "nutrition_daily" + }, + { + "value": "\"protein_g\" IS NULL OR \"protein_g\" BETWEEN 0 AND 1000", + "name": "nutrition_daily_protein_check", + "entityType": "checks", + "schema": "health", + "table": "nutrition_daily" + }, + { + "value": "\"date\" >= DATE '1900-01-01'", + "name": "nutrition_daily_date_check", + "entityType": "checks", + "schema": "health", + "table": "nutrition_daily" + }, + { + "value": "\"wake_time\" > \"bedtime\"", + "name": "sleep_order_check", + "entityType": "checks", + "schema": "health", + "table": "sleep" + }, + { + "value": "\"wake_time\" - \"bedtime\" <= interval '24 hours'", + "name": "sleep_length_check", + "entityType": "checks", + "schema": "health", + "table": "sleep" + }, + { + "value": "\"date\" >= DATE '1900-01-01'", + "name": "sleep_date_check", + "entityType": "checks", + "schema": "health", + "table": "sleep" + }, + { + "value": "length(btrim(\"vaccine\")) > 0", + "name": "vaccinations_vaccine_check", + "entityType": "checks", + "schema": "health", + "table": "vaccinations" + }, + { + "value": "\"dose_no\" IS NULL OR \"dose_no\" BETWEEN 1 AND 20", + "name": "vaccinations_dose_no_check", + "entityType": "checks", + "schema": "health", + "table": "vaccinations" + }, + { + "value": "\"country\" IS NULL OR \"country\" ~ '^[A-Z]{2}$'", + "name": "vaccinations_country_check", + "entityType": "checks", + "schema": "health", + "table": "vaccinations" + }, + { + "value": "\"date\" >= DATE '1900-01-01'", + "name": "vaccinations_date_check", + "entityType": "checks", + "schema": "health", + "table": "vaccinations" + }, + { + "value": "length(btrim(\"exercise\")) > 0", + "name": "workouts_exercise_check", + "entityType": "checks", + "schema": "health", + "table": "workouts" + }, + { + "value": "\"sets\" IS NULL OR \"sets\" BETWEEN 1 AND 100", + "name": "workouts_sets_check", + "entityType": "checks", + "schema": "health", + "table": "workouts" + }, + { + "value": "\"reps\" IS NULL OR \"reps\" BETWEEN 0 AND 1000", + "name": "workouts_reps_check", + "entityType": "checks", + "schema": "health", + "table": "workouts" + }, + { + "value": "\"load_kg\" IS NULL OR \"load_kg\" BETWEEN 0 AND 1000", + "name": "workouts_load_check", + "entityType": "checks", + "schema": "health", + "table": "workouts" + }, + { + "value": "\"rpe\" IS NULL OR \"rpe\" BETWEEN 1 AND 10", + "name": "workouts_rpe_check", + "entityType": "checks", + "schema": "health", + "table": "workouts" + }, + { + "value": "\"date\" >= DATE '1900-01-01'", + "name": "workouts_date_check", + "entityType": "checks", + "schema": "health", + "table": "workouts" + }, + { + "value": "\"percent\" between 0 and 100", + "name": "progress_percent_check", + "entityType": "checks", + "schema": "books", + "table": "progress" + }, + { + "value": "\"rating\" is null or \"rating\" between 1 and 5", + "name": "progress_rating_check", + "entityType": "checks", + "schema": "books", + "table": "progress" + }, + { + "value": "(\"start_percent\" is null or \"start_percent\" between 0 and 100) and (\"end_percent\" is null or \"end_percent\" between 0 and 100)", + "name": "sessions_percent_check", + "entityType": "checks", + "schema": "books", + "table": "sessions" + }, + { + "value": "\"ended_at\" is null or \"ended_at\" >= \"started_at\"", + "name": "sessions_end_check", + "entityType": "checks", + "schema": "books", + "table": "sessions" + }, + { + "value": "\"year\" is null or \"year\" between 0 and 2100", + "name": "works_year_check", + "entityType": "checks", + "schema": "books", + "table": "works" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index fa26edd..cd4268a 100755 --- a/eslint.config.js +++ b/eslint.config.js @@ -34,6 +34,6 @@ export default [ } }, { - ignores: ['build/', '.svelte-kit/', 'dist/'] + ignores: ['build/', '.svelte-kit/', 'dist/', 'static/vendor/'] } ]; diff --git a/package.json b/package.json index c7422d6..9849b4e 100755 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "marked": "^18.0.11", "marked-highlight": "^2.2.4", "mode-watcher": "^1.1.0", + "pdfjs-dist": "^6.4.299", "postgres": "^3.4.9", "topojson-client": "^3.1.0", "world-atlas": "^2.0.2", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index af40e50..d358fa2 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,7 +1,7 @@ import type { Handle } from '@sveltejs/kit/hooks'; import { building } from '$app/env'; import { auth } from '#lib/server/auth.js'; -import { isOwner, isOwnerOnlyPath } from '#lib/server/owner.js'; +import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js'; import { error } from '@sveltejs/kit'; export const handle: Handle = async ({ event, resolve }) => { @@ -11,17 +11,15 @@ export const handle: Handle = async ({ event, resolve }) => { const session = building ? null : await auth.api.getSession({ headers: event.request.headers }); event.locals.user = session?.user ?? null; event.locals.session = session?.session ?? null; - // Owner-only areas (health tracker). Page loads, __data.json and the import endpoint - // 404 for non-owners in the route itself (layout load / endpoint), which renders the - // same 404 page as an unknown URL. As a backstop, any other method (form actions, - // which skip layout loads) is refused here before a route runs. + // Owner-only areas (health tracker, book files, reader and book APIs). Page loads, + // __data.json and endpoints 404 for non-owners in the route itself (layout load / + // endpoint), which renders the same 404 page as an unknown URL. As a backstop, any other + // method (form actions, which skip layout loads) is refused here before a route runs, + // including writes to the otherwise public /trackers/books pages. const ownerOnly = isOwnerOnlyPath(event.url.pathname); - if ( - ownerOnly && - !isOwner(event) && - event.request.method !== 'GET' && - event.request.method !== 'HEAD' - ) + const owner = isOwner(event); + const read = event.request.method === 'GET' || event.request.method === 'HEAD'; + if (!owner && ((ownerOnly && !read) || (!read && isOwnerWritePath(event.url.pathname)))) error(404, 'Not Found'); const response = await resolve(event); if (ownerOnly) { diff --git a/src/lib/books/BookCover.svelte b/src/lib/books/BookCover.svelte new file mode 100644 index 0000000..5d510bf --- /dev/null +++ b/src/lib/books/BookCover.svelte @@ -0,0 +1,137 @@ + + +
+ {#if showImage} + (failed = src)} + /> + {:else} + + {/if} +
+ + diff --git a/src/lib/books/Stars.svelte b/src/lib/books/Stars.svelte new file mode 100644 index 0000000..f50c4df --- /dev/null +++ b/src/lib/books/Stars.svelte @@ -0,0 +1,68 @@ + + +{#if name || onchange} + + {#each [1, 2, 3, 4, 5] as n (n)} + + {/each} + +{:else if value} + + {#each [1, 2, 3, 4, 5] as n (n)} + +{/if} + + diff --git a/src/lib/books/progress.test.ts b/src/lib/books/progress.test.ts new file mode 100644 index 0000000..9425ccf --- /dev/null +++ b/src/lib/books/progress.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { pagesRead, parseProgress } from './progress'; +import { parsePdfLocator, pdfLocator } from './shared'; + +describe('progress payload', () => { + it('accepts a reader save and rounds the percent', () => { + expect( + parseProgress({ + fileId: 3, + locator: 'epubcfi(/6/4!/4/2)', + percent: 12.34567, + sessionId: null + }) + ).toMatchObject({ fileId: 3, locator: 'epubcfi(/6/4!/4/2)', percent: 12.35 }); + }); + it.each([ + null, + 'nope', + {}, + { percent: -1 }, + { percent: 101 }, + { percent: 'ten' }, + { percent: 10, page: 1.5 }, + { percent: 10, fileId: -2 }, + { percent: 10, locator: 'x'.repeat(2001) } + ])('rejects %j', (body) => { + expect(parseProgress(body)).toBeNull(); + }); +}); + +describe('pages read', () => { + it('uses the page delta when pages are known', () => { + expect(pagesRead({ startPage: 10, endPage: 34 })).toBe(24); + }); + it('scales the percent delta by the page count', () => { + expect(pagesRead({ startPercent: 10, endPercent: 20, pageCount: 300 })).toBe(30); + }); + it('never goes negative and needs enough data', () => { + expect(pagesRead({ startPage: 50, endPage: 20 })).toBe(0); + expect(pagesRead({ startPercent: 40, endPercent: 30, pageCount: 100 })).toBe(0); + expect(pagesRead({ startPercent: 10, endPercent: 20 })).toBeNull(); + }); +}); + +describe('pdf locators', () => { + it('round-trips pages and rects', () => { + const loc = pdfLocator(12, [{ x: 0.1, y: 0.2, w: 0.3333333, h: 0.02 }]); + expect(loc).toBe('page:12#r=0.1,0.2,0.3333,0.02'); + expect(parsePdfLocator(loc)).toEqual({ + page: 12, + rects: [{ x: 0.1, y: 0.2, w: 0.3333, h: 0.02 }] + }); + expect(parsePdfLocator('page:3')).toEqual({ page: 3, rects: [] }); + expect(parsePdfLocator('epubcfi(/6/2)')).toBeNull(); + }); +}); diff --git a/src/lib/books/progress.ts b/src/lib/books/progress.ts new file mode 100644 index 0000000..220e5f8 --- /dev/null +++ b/src/lib/books/progress.ts @@ -0,0 +1,41 @@ +// Validation and maths for the reader's progress API (pure, unit tested). +import { z } from 'zod'; + +export const progressPayload = z.object({ + fileId: z.number().int().positive().nullish(), + locator: z.string().max(2000).nullish(), + page: z.number().int().min(0).max(100000).nullish(), + percent: z.number().min(0).max(100), + sessionId: z.number().int().positive().nullish(), + /** Total pages in the opened file (PDF), used for pages-read on sessions. */ + pageCount: z.number().int().positive().max(100000).nullish() +}); +export type ProgressPayload = z.infer; + +export function parseProgress(body: unknown): ProgressPayload | null { + const result = progressPayload.safeParse(body); + if (!result.success) return null; + const value = result.data; + return { ...value, percent: Math.round(value.percent * 100) / 100 }; +} + +/** + * Pages read in a session: the page delta when both pages are known (PDF), otherwise the + * percent delta scaled by the book's page count. Never negative (going back isn't reading). + */ +export function pagesRead(input: { + startPage?: number | null; + endPage?: number | null; + startPercent?: number | null; + endPercent?: number | null; + pageCount?: number | null; +}): number | null { + const { startPage, endPage, startPercent, endPercent, pageCount } = input; + if (startPage != null && endPage != null) return Math.max(0, endPage - startPage); + if (startPercent != null && endPercent != null && pageCount) + return Math.max(0, Math.round(((endPercent - startPercent) / 100) * pageCount)); + return null; +} + +/** A reader session continues if the last save was less than this long ago. */ +export const SESSION_GAP_MS = 30 * 60 * 1000; diff --git a/src/lib/books/reader/EpubReader.svelte b/src/lib/books/reader/EpubReader.svelte new file mode 100644 index 0000000..0994cfd --- /dev/null +++ b/src/lib/books/reader/EpubReader.svelte @@ -0,0 +1,228 @@ + + +
+ +
+ + diff --git a/src/lib/books/reader/PdfReader.svelte b/src/lib/books/reader/PdfReader.svelte new file mode 100644 index 0000000..88a669d --- /dev/null +++ b/src/lib/books/reader/PdfReader.svelte @@ -0,0 +1,538 @@ + + +
+
+ {#each pages as p (p.num)} + +
hitHighlight(e, p.num)} + > +
+ {#each byPage.get(p.num) ?? [] as { h, rects } (h.id)} + {#each rects as r, i (i)} + + {/each} + {/each} + {p.num} +
+ {/each} +
+
+ + diff --git a/src/lib/books/reader/types.ts b/src/lib/books/reader/types.ts new file mode 100644 index 0000000..d63072c --- /dev/null +++ b/src/lib/books/reader/types.ts @@ -0,0 +1,89 @@ +// Contract between the reader shell (the /read page) and the EPUB/PDF engines. +import type { Highlight } from '#lib/books/shared.js'; + +export type TocItem = { label: string; target: string | number; children: TocItem[] }; + +export type Relocation = { + percent: number; // 0-100 + locator: string; // EPUB CFI or `page:N` + page: number | null; // PDF page (1-based), or null for reflowable books + pageCount: number | null; + label: string | null; // current chapter +}; + +export type TextSelection = { text: string; locator: string; page: number | null }; + +export type ReaderTheme = 'light' | 'sepia' | 'dark'; +export type ReaderSettings = { + theme: 'auto' | ReaderTheme; + fontSize: number; // percent + lineHeight: number; + font: 'publisher' | 'serif' | 'sans'; + flow: 'paginated' | 'scrolled'; + pdfZoom: number; // 1 = fit width +}; +export const defaultSettings: ReaderSettings = { + theme: 'auto', + fontSize: 100, + lineHeight: 1.5, + font: 'publisher', + flow: 'paginated', + pdfZoom: 1 +}; + +export const themeColors: Record = { + light: { bg: '#fdfdfb', fg: '#1c1c1c', link: '#2563eb' }, + sepia: { bg: '#f4ecd8', fg: '#433422', link: '#8a4b14' }, + dark: { bg: '#121214', fg: '#d8d8d8', link: '#7aa8ff' } +}; + +/** What both engines expose to the shell through bind:this. */ +export interface ReaderEngine { + goTo(target: string | number): Promise; + goToPercent(percent: number): Promise; + next(): void; + prev(): void; + addHighlight(h: Highlight): void; + removeHighlight(h: Highlight): void; + clearSelection(): void; +} + +export type EngineProps = { + url: string; + name: string; + start: string | null; + startPercent: number; + highlights: Highlight[]; + settings: ReaderSettings; + theme: ReaderTheme; + onready: (toc: TocItem[]) => void; + onrelocate: (r: Relocation) => void; + onselection: (s: TextSelection | null) => void; + onhighlightclick: (id: number) => void; + onkey: (e: KeyboardEvent) => void; + onloading: (fraction: number | null) => void; + onerror: (message: string) => void; +}; + +/** Downloads a file with progress, so big books show a loading bar instead of a blank page. */ +export async function fetchWithProgress( + url: string, + onprogress: (fraction: number | null) => void, + signal?: AbortSignal +): Promise { + const res = await fetch(url, { signal, credentials: 'same-origin' }); + if (!res.ok) throw new Error(res.status === 404 ? 'File not found' : `HTTP ${res.status}`); + const total = Number(res.headers.get('content-length')) || 0; + if (!res.body || !total) return res.blob(); + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let loaded = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + loaded += value.byteLength; + onprogress(Math.min(1, loaded / total)); + } + return new Blob(chunks, { type: res.headers.get('content-type') ?? '' }); +} diff --git a/src/lib/books/shared.ts b/src/lib/books/shared.ts new file mode 100644 index 0000000..3333b95 --- /dev/null +++ b/src/lib/books/shared.ts @@ -0,0 +1,104 @@ +// Shared (client + server) types and helpers for the book tracker. +export const readingStatuses = ['want_to_read', 'reading', 'finished', 'abandoned'] as const; +export type ReadingStatus = (typeof readingStatuses)[number]; + +export const statusLabels: Record = { + want_to_read: 'want to read', + reading: 'reading', + finished: 'finished', + abandoned: 'abandoned' +}; + +export function isReadingStatus(value: unknown): value is ReadingStatus { + return typeof value === 'string' && (readingStatuses as readonly string[]).includes(value); +} + +/** Formats the in-browser reader can open. MOBI/AZW3/FB2/CBZ go through foliate-js too. */ +export const readableFormats = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz'] as const; +export function isReadable(format: string): boolean { + return (readableFormats as readonly string[]).includes(format.toLowerCase()); +} +/** Preferred file to open when a work has several formats. */ +export const formatPreference = ['epub', 'azw3', 'mobi', 'azw', 'fb2', 'pdf', 'cbz']; + +/** One card in the library. Public fields only, unless noted. */ +export type BookCard = { + id: number; + title: string; + subtitle: string | null; + authors: string[]; + year: number | null; + tags: string[]; + pageCount: number | null; + cover: string | null; // URL (our cover endpoint or a remote cover), or null for a generated one + status: ReadingStatus | null; // null: never touched + percent: number; + rating: number | null; + lastReadAt: string | null; + finishedAt: string | null; + addedAt: string; + // Owner only (empty / false for everyone else): + formats: string[]; + hasFile: boolean; +}; + +export type LibraryStats = { + total: number; + finishedThisYear: number; + pagesThisYear: number; + reading: number; + year: number; +}; + +export type Highlight = { + id: number; + fileId: number | null; + locator: string | null; + page: number | null; + highlight: string | null; + note: string | null; + color: string | null; + createdAt: string; +}; + +export const highlightColors = ['yellow', 'green', 'blue', 'pink', 'purple'] as const; +export type HighlightColor = (typeof highlightColors)[number]; +export const highlightCss: Record = { + yellow: '#facc15', + green: '#4ade80', + blue: '#60a5fa', + pink: '#f472b6', + purple: '#c084fc' +}; + +/** Deterministic hue for generated covers, so a book always gets the same colour. */ +export function coverHue(title: string): number { + let hash = 0; + for (const char of title) hash = (hash * 31 + char.codePointAt(0)!) >>> 0; + return hash % 360; +} + +export function authorLine(authors: readonly string[]): string { + if (authors.length <= 2) return authors.join(' & '); + return `${authors[0]} et al.`; +} + +/** PDF highlight locators: `page:12` or `page:12#r=x,y,w,h;x,y,w,h` (rects as page fractions). */ +export type Rect = { x: number; y: number; w: number; h: number }; +export function pdfLocator(page: number, rects: Rect[] = []): string { + const r = rects + .map((rect) => [rect.x, rect.y, rect.w, rect.h].map((n) => +n.toFixed(4)).join(',')) + .join(';'); + return r ? `page:${page}#r=${r}` : `page:${page}`; +} +export function parsePdfLocator(locator: string | null): { page: number; rects: Rect[] } | null { + const match = /^page:(\d+)(?:#r=([\d.,;-]+))?$/.exec(locator ?? ''); + if (!match) return null; + const rects = (match[2] ?? '') + .split(';') + .filter(Boolean) + .map((part) => part.split(',').map(Number)) + .filter((n) => n.length === 4 && n.every(Number.isFinite)) + .map(([x, y, w, h]) => ({ x, y, w, h })); + return { page: Number(match[1]), rects }; +} diff --git a/src/lib/components/ProjectIcon.svelte b/src/lib/components/ProjectIcon.svelte index c3ccb2e..7d7d37e 100644 --- a/src/lib/components/ProjectIcon.svelte +++ b/src/lib/components/ProjectIcon.svelte @@ -8,6 +8,7 @@ + +{#snippet progressBar(b: BookCard, thin = false)} + {#if b.status === 'reading' || (b.percent > 0 && b.status !== 'finished')} + + {/if} +{/snippet} + +{#snippet statusChip(s: ReadingStatus | null)} + {#if s}{statusLabels[s]}{/if} +{/snippet} + +
+
+

books

+

+ What I'm reading, what I've read and what's next. +

+
+ +
+
+
finished in {data.stats.year}
+
{numberFormat.format(data.stats.finishedThisYear)}
+
+
+
pages read in {data.stats.year}
+
{numberFormat.format(data.stats.pagesThisYear)}
+
+
+
currently reading
+
{data.stats.reading}
+
+
+
in the library
+
+ {numberFormat.format(data.stats.total)} + {#if placeholders}{placeholders} placeholder{placeholders === 1 ? '' : 's'}{/if} +
+
+
+ + {#if reading.length && !filtering} +
+

now reading

+
    + {#each reading.slice(0, 6) as b (b.id)} +
  • + + + +
    + {b.title} +

    + {authorLine(b.authors)} +

    +
    + + {pct(b.percent)} +
    + {#if data.owner && b.hasFile} + continue + {/if} +
    +
  • + {/each} +
+
+ {/if} + +
+ +
+ {#if subjects.length} + + + {#each subjects as [name, count] (name)}{/each} + + {/if} + {#if data.owner && (formats.length || placeholders)} + + + {#each formats as f (f)}{/each} + {#if placeholders}{/if} + + {/if} + + {#each Object.entries(sorts) as [key, label] (key)}{/each} + +
+ + +
+
+
+ +
+ {#each ['all', ...readingStatuses] as const as s (s)} + + {/each} + {#if counts.none} + + {/if} +
+ + {#if filtering} +

+ {filtered.length} of {data.books.length} books + +

+ {/if} + + {#if !data.books.length} +
+
+ {:else if !filtered.length} +

No books match these filters.

+ {:else if view === 'grid'} + + {:else} + + {/if} +
+ + diff --git a/src/routes/trackers/books/[id=integer]/+page.server.ts b/src/routes/trackers/books/[id=integer]/+page.server.ts new file mode 100644 index 0000000..4e3bf28 --- /dev/null +++ b/src/routes/trackers/books/[id=integer]/+page.server.ts @@ -0,0 +1,182 @@ +import { error, fail } from '@sveltejs/kit'; +import { and, eq } from 'drizzle-orm'; +import { db } from '#lib/server/db/index.js'; +import { bookSessions } from '#lib/server/db/schema/index.js'; +import { isOwner, requireOwner } from '#lib/server/owner.js'; +import { + coverSrc, + getWork, + logManualSession, + setManualProgress, + setRating, + setStatus, + workFiles, + workNotes, + workSessions +} from '#lib/server/books/library.js'; +import { createNote, deleteNote, updateNote } from '#lib/server/books/notes.js'; +import { isReadingStatus } from '#lib/books/shared.js'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async (event) => { + const id = Number(event.params.id); + const found = await getWork(id); + if (!found) error(404, 'Not Found'); + const owner = isOwner(event); + const { work, progress } = found; + const book = { + id: work.id, + title: work.title, + subtitle: work.subtitle, + authors: work.authors, + year: work.year, + publisher: work.publisher, + isbn13: work.isbn13, + isbn10: work.isbn10, + language: work.language, + pageCount: work.pageCount, + description: work.description, + tags: work.tags, + cover: coverSrc(work), + openlibraryWorkId: work.openlibraryWorkId + }; + const reading = { + status: progress?.status ?? null, + percent: progress?.percent ?? 0, + page: progress?.page ?? null, + rating: progress?.rating ?? null, + startedAt: progress?.startedAt?.toISOString() ?? null, + finishedAt: progress?.finishedAt?.toISOString() ?? null, + lastReadAt: progress?.lastReadAt?.toISOString() ?? null + }; + const seo = { + title: `${work.title}${work.authors.length ? ` by ${work.authors.join(', ')}` : ''}`, + description: + work.description?.slice(0, 160) ?? + `${work.title} on Stan Runge's bookshelf${reading.status ? ` (${reading.status.replace(/_/g, ' ')})` : ''}.` + }; + if (!owner) return { book, reading, owner: false as const, seo }; + + event.setHeaders({ 'cache-control': 'private, no-store' }); + const [files, notes, sessions] = await Promise.all([ + workFiles(id), + workNotes(id), + workSessions(id) + ]); + return { + book, + reading, + owner: true as const, + seo, + files: files.map(({ id, format, sizeBytes, readable, path }) => ({ + id, + format, + sizeBytes, + readable, + name: path.split('/').at(-1) ?? path + })), + notes, + sessions + }; +}; + +const int = (value: FormDataEntryValue | null) => { + if (value === null || value === '') return null; + const n = Number(value); + return Number.isInteger(n) ? n : NaN; +}; +const date = (value: FormDataEntryValue | null) => { + if (!value) return null; + const d = new Date(String(value)); + return Number.isNaN(d.getTime()) ? undefined : d; +}; + +export const actions: Actions = { + status: async (event) => { + requireOwner(event); + const status = (await event.request.formData()).get('status'); + if (!isReadingStatus(status)) return fail(400, { message: 'Unknown status' }); + await setStatus(Number(event.params.id), status); + return { ok: true }; + }, + rating: async (event) => { + requireOwner(event); + const rating = int((await event.request.formData()).get('rating')); + if (rating !== null && !(rating >= 1 && rating <= 5)) + return fail(400, { message: 'Rating is 1-5' }); + await setRating(Number(event.params.id), rating); + return { ok: true }; + }, + progress: async (event) => { + requireOwner(event); + const form = await event.request.formData(); + const page = int(form.get('page')); + const pageCount = int(form.get('pageCount')); + let percent = form.get('percent') === '' ? null : Number(form.get('percent')); + if (Number.isNaN(page) || (percent !== null && !(percent >= 0 && percent <= 100))) + return fail(400, { message: 'Invalid progress' }); + if (percent === null && page !== null && pageCount) + percent = Math.min(100, (page / pageCount) * 100); + if (percent === null) return fail(400, { message: 'Enter a percent or a page' }); + await setManualProgress(Number(event.params.id), Math.round(percent * 100) / 100, page); + return { ok: true }; + }, + addNote: async (event) => { + requireOwner(event); + const form = await event.request.formData(); + const note = String(form.get('note') ?? '').trim(); + const page = int(form.get('page')); + if (!note) return fail(400, { message: 'Write something first' }); + if (Number.isNaN(page)) return fail(400, { message: 'Invalid page' }); + await createNote(Number(event.params.id), { note, page }); + return { ok: true }; + }, + editNote: async (event) => { + requireOwner(event); + const form = await event.request.formData(); + const id = int(form.get('id')); + if (!id) return fail(400, { message: 'Missing note' }); + const updated = await updateNote(Number(event.params.id), id, { + note: String(form.get('note') ?? '').trim() || null + }); + if (!updated) return fail(404, { message: 'Note not found' }); + return { ok: true }; + }, + deleteNote: async (event) => { + requireOwner(event); + const id = int((await event.request.formData()).get('id')); + if (!id || !(await deleteNote(Number(event.params.id), id))) + return fail(404, { message: 'Note not found' }); + return { ok: true }; + }, + logSession: async (event) => { + requireOwner(event); + const form = await event.request.formData(); + const startedAt = date(form.get('startedAt')); + const endedAt = date(form.get('endedAt')); + const startPage = int(form.get('startPage')); + const endPage = int(form.get('endPage')); + if (!startedAt || endedAt === undefined || Number.isNaN(startPage) || Number.isNaN(endPage)) + return fail(400, { message: 'Check the dates and pages' }); + if (endedAt && endedAt < startedAt) return fail(400, { message: 'End is before start' }); + await logManualSession(Number(event.params.id), { + startedAt, + endedAt, + startPage, + endPage, + startPercent: null, + endPercent: null, + note: String(form.get('note') ?? '').trim() || null + }); + return { ok: true }; + }, + deleteSession: async (event) => { + requireOwner(event); + const id = int((await event.request.formData()).get('id')); + if (!id) return fail(400, { message: 'Missing session' }); + await db + .delete(bookSessions) + .where(and(eq(bookSessions.id, id), eq(bookSessions.workId, Number(event.params.id)))); + return { ok: true }; + } +}; diff --git a/src/routes/trackers/books/[id=integer]/+page.svelte b/src/routes/trackers/books/[id=integer]/+page.svelte new file mode 100644 index 0000000..034f6b2 --- /dev/null +++ b/src/routes/trackers/books/[id=integer]/+page.svelte @@ -0,0 +1,683 @@ + + +
+ all books + +
+ + +
+

{book.title}

+ {#if book.subtitle}

{book.subtitle}

{/if} + {#if book.authors.length}

{book.authors.join(', ')}

{/if} + + {#if meta.length} +
+ {#each meta as [label, value] (label)} +
+
{label}
+
{value}
+
+ {/each} +
+ {/if} + + {#if book.tags.length} +
    + {#each book.tags.slice(0, 16) as tag (tag)}
  • {tag}
  • {/each} +
+ {/if} + + {#if book.description} +
+

{book.description}

+ {#if book.description.length > 400} + + {/if} +
+ {/if} + +
+
+

progress

+

+ {#if reading.startedAt}started {dateFormat.format(new Date(reading.startedAt))}{/if} + {#if reading.finishedAt}· finished {dateFormat.format( + new Date(reading.finishedAt) + )}{/if} + {#if reading.lastReadAt && reading.status === 'reading'}· last read {timeFormat.format( + new Date(reading.lastReadAt) + )}{/if} +

+
+
+ + {Math.round(reading.percent)}% +
+ {#if reading.page && book.pageCount} +

+ page {reading.page} of {book.pageCount} +

+ {/if} + {#if data.owner} +
+ + {#if book.pageCount} + + + {/if} + +
+ {/if} +
+ + {#if data.owner} + {#if form?.message}{/if} + +
+

notes

+
+ + {:else if selection} +
+ {#each highlightColors as c (c)} + + {/each} + + + +
+ {#if noteFor === 'selection'} + + + {/if} + {/if} +
+ {/if} + +
+ {fmtPercent(percent)} + (scrubbing = Number(e.currentTarget.value))} + onchange={async (e) => { + await engine?.goToPercent(Number(e.currentTarget.value)); + scrubbing = null; + }} + /> + + {#if location?.page && location.pageCount}{location.page} / {location.pageCount}{:else if saveState === 'saving'}saving…{:else if saveState === 'error'}not + saved{:else if saveState === 'saved'}saved{/if} + +
+
+ + diff --git a/src/routes/trackers/books/auth.test.ts b/src/routes/trackers/books/auth.test.ts new file mode 100644 index 0000000..400f643 --- /dev/null +++ b/src/routes/trackers/books/auth.test.ts @@ -0,0 +1,157 @@ +// Auth gating for the book tracker: files, reader, progress and notes are owner-only and +// answer 404 to everyone else; the public pages never leak files or notes. +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { isHttpError } from '@sveltejs/kit'; + +vi.mock('#lib/server/db/index.js', () => ({ db: {} })); +vi.mock('#lib/server/books/library.js', () => ({ + coverSrc: vi.fn(() => null), + getWork: vi.fn(), + workFiles: vi.fn(async () => []), + workNotes: vi.fn(async () => []), + workSessions: vi.fn(async () => []), + saveProgress: vi.fn(async () => ({ sessionId: 7 })), + setStatus: vi.fn(), + setRating: vi.fn(), + setManualProgress: vi.fn(), + logManualSession: vi.fn(), + libraryCards: vi.fn(async () => []), + libraryStats: vi.fn(async () => ({})) +})); +vi.mock('#lib/server/books/notes.js', async (original) => ({ + ...(await original()), + createNote: vi.fn(async () => ({ id: 1 })), + updateNote: vi.fn(async () => ({ id: 1 })), + deleteNote: vi.fn(async () => true) +})); + +import * as library from '#lib/server/books/library.js'; +import * as notes from '#lib/server/books/notes.js'; +import { POST as progress } from './[id=integer]/progress/+server'; +import { POST as createNote } from './[id=integer]/notes/+server'; +import { + PATCH as patchNote, + DELETE as deleteNote +} from './[id=integer]/notes/[noteId=integer]/+server'; +import { GET as file } from './files/[id=integer]/+server'; +import { load as readerLoad } from './[id=integer]/read/+page.server'; +import { actions, load as detailLoad } from './[id=integer]/+page.server'; + +const OWNER = { id: 'u1', email: 'stan@stanrunge.dev', role: 'admin' }; +const STRANGER = { id: 'u2', email: 'someone@example.com', role: 'admin' }; + +function event(user: object | null, init: RequestInit = {}, params: Record = {}) { + return { + locals: { user, session: null }, + params: { id: '1', noteId: '1', ...params }, + request: new Request('https://site.test/trackers/books/1', init), + url: new URL('https://site.test/trackers/books/1'), + setHeaders: vi.fn() + } as never; +} + +async function status(fn: () => unknown) { + try { + const result = await fn(); + return result instanceof Response ? result.status : 200; + } catch (e) { + if (isHttpError(e)) return e.status; + throw e; + } +} + +const json = (body: unknown, method = 'POST') => ({ + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) +}); + +const work = { + id: 1, + title: 'Dune', + subtitle: null, + authors: ['Frank Herbert'], + year: 1965, + publisher: null, + isbn13: null, + isbn10: null, + language: 'en', + pageCount: 412, + description: null, + tags: [], + coverPath: null, + coverUrl: null, + openlibraryWorkId: null +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(library.getWork).mockResolvedValue({ work, progress: null } as never); +}); + +describe.each([ + ['anonymous', null], + ['another signed-in user', STRANGER] +])('%s', (_, user) => { + it('gets 404 from every owner-only endpoint', async () => { + expect(await status(() => progress(event(user, json({ percent: 10 }))))).toBe(404); + expect(await status(() => createNote(event(user, json({ note: 'hi' }))))).toBe(404); + expect(await status(() => patchNote(event(user, json({ note: 'x' }, 'PATCH'))))).toBe(404); + expect(await status(() => deleteNote(event(user, { method: 'DELETE' })))).toBe(404); + expect(await status(() => file(event(user)))).toBe(404); + expect(await status(() => readerLoad(event(user)))).toBe(404); + for (const action of Object.values(actions)) + expect( + await status(() => action(event(user, { method: 'POST', body: new FormData() }))) + ).toBe(404); + expect(library.saveProgress).not.toHaveBeenCalled(); + expect(notes.createNote).not.toHaveBeenCalled(); + expect(library.setStatus).not.toHaveBeenCalled(); + }); + it('sees the public catalogue page without files, notes or sessions', async () => { + const data = (await detailLoad(event(user))) as Record; + expect(data.owner).toBe(false); + expect(data).not.toHaveProperty('files'); + expect(data).not.toHaveProperty('notes'); + expect(data).not.toHaveProperty('sessions'); + expect(library.workNotes).not.toHaveBeenCalled(); + }); +}); + +describe('owner', () => { + it('saves progress and gets a session id back', async () => { + const res = await progress( + event(OWNER, json({ fileId: 2, locator: 'page:5', page: 5, percent: 1.234 })) + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ sessionId: 7 }); + expect(library.saveProgress).toHaveBeenCalledWith( + 1, + expect.objectContaining({ percent: 1.23, page: 5 }) + ); + }); + it('rejects invalid progress', async () => { + expect(await status(() => progress(event(OWNER, json({ percent: 140 }))))).toBe(400); + expect(await status(() => progress(event(OWNER, { method: 'POST', body: '{' })))).toBe(400); + expect(library.saveProgress).not.toHaveBeenCalled(); + }); + it('404s progress for a book that does not exist', async () => { + vi.mocked(library.saveProgress).mockResolvedValueOnce(null); + expect(await status(() => progress(event(OWNER, json({ percent: 3 }))))).toBe(404); + }); + it('creates highlights and refuses empty notes', async () => { + expect( + await status(() => createNote(event(OWNER, json({ highlight: 'quote', locator: 'page:1' })))) + ).toBe(201); + expect(await status(() => createNote(event(OWNER, json({ note: ' ' }))))).toBe(400); + }); + it('sees notes, files and sessions on the detail page', async () => { + const data = (await detailLoad(event(OWNER))) as Record; + expect(data.owner).toBe(true); + expect(data).toHaveProperty('notes'); + expect(data).toHaveProperty('files'); + }); + it('gets 404 from the reader when the book has no readable file', async () => { + expect(await status(() => readerLoad(event(OWNER)))).toBe(404); + }); +}); diff --git a/src/routes/trackers/books/covers/[id=integer]/+server.ts b/src/routes/trackers/books/covers/[id=integer]/+server.ts new file mode 100644 index 0000000..50dd560 --- /dev/null +++ b/src/routes/trackers/books/covers/[id=integer]/+server.ts @@ -0,0 +1,23 @@ +// Public cover images for the catalogue (covers only, never book files). +import { error } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; +import { db } from '#lib/server/db/index.js'; +import { bookWorks } from '#lib/server/db/schema/index.js'; +import { safeResolve, streamFile } from '#lib/server/books/files.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const [work] = await db + .select({ coverPath: bookWorks.coverPath }) + .from(bookWorks) + .where(eq(bookWorks.id, Number(event.params.id))) + .limit(1); + const resolved = work?.coverPath ? await safeResolve('covers', work.coverPath) : null; + if (!resolved) error(404, 'Not Found'); + const response = await streamFile(resolved, event.request, { + 'cache-control': 'public, max-age=86400, stale-while-revalidate=604800', + 'x-content-type-options': 'nosniff' + }); + if (!response.headers.get('content-type')?.startsWith('image/')) error(404, 'Not Found'); + return response; +}; diff --git a/src/routes/trackers/books/files/[id=integer]/+server.ts b/src/routes/trackers/books/files/[id=integer]/+server.ts new file mode 100644 index 0000000..19e7c57 --- /dev/null +++ b/src/routes/trackers/books/files/[id=integer]/+server.ts @@ -0,0 +1,30 @@ +// Owner-only book file download/stream for the reader. Everyone else gets 404, the same as +// a file that doesn't exist. Files are read from BOOKS_ROOT/library, never from static/. +import { error } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; +import { db } from '#lib/server/db/index.js'; +import { bookFiles } from '#lib/server/db/schema/index.js'; +import { requireOwner } from '#lib/server/owner.js'; +import { safeResolve, streamFile } from '#lib/server/books/files.js'; +import type { RequestHandler } from './$types'; + +const handler: RequestHandler = async (event) => { + requireOwner(event); + const [file] = await db + .select({ path: bookFiles.path }) + .from(bookFiles) + .where(eq(bookFiles.id, Number(event.params.id))) + .limit(1); + const resolved = file ? await safeResolve('library', file.path) : null; + if (!resolved) error(404, 'Not Found'); + const name = resolved.split('/').at(-1) ?? 'book'; + return streamFile(resolved, event.request, { + 'cache-control': 'private, no-store', + 'x-robots-tag': 'noindex, nofollow, noarchive', + 'x-content-type-options': 'nosniff', + 'content-disposition': `inline; filename*=UTF-8''${encodeURIComponent(name)}` + }); +}; + +export const GET = handler; +export const HEAD = handler; diff --git a/static/vendor/foliate-js/LICENSE b/static/vendor/foliate-js/LICENSE new file mode 100644 index 0000000..5930571 --- /dev/null +++ b/static/vendor/foliate-js/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 John Factotum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/static/vendor/foliate-js/VERSION b/static/vendor/foliate-js/VERSION new file mode 100644 index 0000000..c2778e2 --- /dev/null +++ b/static/vendor/foliate-js/VERSION @@ -0,0 +1 @@ +foliate-js @ 78914aef4466eb960965702401634c2cb348e9b1 (MIT), https://github.com/johnfactotum/foliate-js. vendor/pdfjs omitted: PDFs use pdfjs-dist from npm. diff --git a/static/vendor/foliate-js/comic-book.js b/static/vendor/foliate-js/comic-book.js new file mode 100644 index 0000000..cf41d11 --- /dev/null +++ b/static/vendor/foliate-js/comic-book.js @@ -0,0 +1,45 @@ +export const makeComicBook = ({ entries, loadBlob, getSize }, file) => { + const cache = new Map() + const urls = new Map() + const load = async name => { + if (cache.has(name)) return cache.get(name) + const src = URL.createObjectURL(await loadBlob(name)) + const page = URL.createObjectURL( + new Blob([``], { type: 'text/html' })) + urls.set(name, [src, page]) + cache.set(name, page) + return page + } + const unload = name => { + urls.get(name)?.forEach?.(url => URL.revokeObjectURL(url)) + urls.delete(name) + cache.delete(name) + } + + const exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.jxl', '.avif'] + const files = entries + .map(entry => entry.filename) + .filter(name => exts.some(ext => name.endsWith(ext))) + .sort(new Intl.Collator([], { numeric: true }).compare) + if (!files.length) throw new Error('No supported image files in archive') + + const book = {} + book.getCover = () => loadBlob(files[0]) + book.metadata = { title: file.name } + book.sections = files.map(name => ({ + id: name, + load: () => load(name), + unload: () => unload(name), + size: getSize(name), + })) + book.toc = files.map(name => ({ label: name, href: name })) + book.rendition = { layout: 'pre-paginated' } + book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) }) + book.splitTOCHref = href => [href, null] + book.getTOCFragment = doc => doc.documentElement + book.destroy = () => { + for (const arr of urls.values()) + for (const url of arr) URL.revokeObjectURL(url) + } + return book +} diff --git a/static/vendor/foliate-js/dict.js b/static/vendor/foliate-js/dict.js new file mode 100644 index 0000000..9afa7ea --- /dev/null +++ b/static/vendor/foliate-js/dict.js @@ -0,0 +1,241 @@ +const decoder = new TextDecoder() +const decode = decoder.decode.bind(decoder) + +const concatTypedArray = (a, b) => { + const result = new a.constructor(a.length + b.length) + result.set(a) + result.set(b, a.length) + return result +} + +const strcmp = (a, b) => { + a = a.toLowerCase(), b = b.toLowerCase() + return a < b ? -1 : a > b ? 1 : 0 +} + +class DictZip { + #chlen + #chunks + #compressed + inflate + async load(file) { + const header = new DataView(await file.slice(0, 12).arrayBuffer()) + if (header.getUint8(0) !== 31 || header.getUint8(1) !== 139 + || header.getUint8(2) !== 8) throw new Error('Not a DictZip file') + const flg = header.getUint8(3) + if (!flg & 0b100) throw new Error('Missing FEXTRA flag') + + const xlen = header.getUint16(10, true) + const extra = new DataView(await file.slice(12, 12 + xlen).arrayBuffer()) + if (extra.getUint8(0) !== 82 || extra.getUint8(1) !== 65) + throw new Error('Subfield ID should be RA') + if (extra.getUint16(4, true) !== 1) throw new Error('Unsupported version') + + this.#chlen = extra.getUint16(6, true) + const chcnt = extra.getUint16(8, true) + this.#chunks = [] + for (let i = 0, chunkOffset = 0; i < chcnt; i++) { + const chunkSize = extra.getUint16(10 + 2 * i, true) + this.#chunks.push([chunkOffset, chunkSize]) + chunkOffset = chunkOffset + chunkSize + } + + // skip to compressed data + let offset = 12 + xlen + const max = Math.min(offset + 512, file.size) + const strArr = new Uint8Array(await file.slice(0, max).arrayBuffer()) + if (flg & 0b1000) { // fname + const i = strArr.indexOf(0, offset) + if (i < 0) throw new Error('Header too long') + offset = i + 1 + } + if (flg & 0b10000) { // fcomment + const i = strArr.indexOf(0, offset) + if (i < 0) throw new Error('Header too long') + offset = i + 1 + } + if (flg & 0b10) offset += 2 // fhcrc + this.#compressed = file.slice(offset) + } + async read(offset, size) { + const chunks = this.#chunks + const startIndex = Math.trunc(offset / this.#chlen) + const endIndex = Math.trunc((offset + size) / this.#chlen) + const buf = await this.#compressed.slice(chunks[startIndex][0], + chunks[endIndex][0] + chunks[endIndex][1]).arrayBuffer() + let arr = new Uint8Array() + for (let pos = 0, i = startIndex; i <= endIndex; i++) { + const data = new Uint8Array(buf, pos, chunks[i][1]) + arr = concatTypedArray(arr, await this.inflate(data)) + pos += chunks[i][1] + } + const startOffset = offset - startIndex * this.#chlen + return arr.subarray(startOffset, startOffset + size) + } +} + +class Index { + strcmp = strcmp + // binary search + bisect(query, start = 0, end = this.words.length - 1) { + if (end - start === 1) { + if (!this.strcmp(query, this.getWord(start))) return start + if (!this.strcmp(query, this.getWord(end))) return end + return null + } + const mid = Math.floor(start + (end - start) / 2) + const cmp = this.strcmp(query, this.getWord(mid)) + if (cmp < 0) return this.bisect(query, start, mid) + if (cmp > 0) return this.bisect(query, mid, end) + return mid + } + // check for multiple definitions + checkAdjacent(query, i) { + if (i == null) return [] + let j = i + const equals = i => { + const word = this.getWord(i) + return word ? this.strcmp(query, word) === 0 : false + } + while (equals(j - 1)) j-- + let k = i + while (equals(k + 1)) k++ + return j === k ? [i] : Array.from({ length: k + 1 - j }, (_, i) => j + i) + } + lookup(query) { + return this.checkAdjacent(query, this.bisect(query)) + } +} + +const decodeBase64Number = str => { + const { length } = str + let n = 0 + for (let i = 0; i < length; i++) { + const c = str.charCodeAt(i) + n += (c === 43 ? 62 // "+" + : c === 47 ? 63 // "/" + : c < 58 ? c + 4 // 0-9 -> 52-61 + : c < 91 ? c - 65 // A-Z -> 0-25 + : c - 71 // a-z -> 26-51 + ) * 64 ** (length - 1 - i) + } + return n +} + +class DictdIndex extends Index { + getWord(i) { + return this.words[i] + } + async load(file) { + const words = [] + const offsets = [] + const sizes = [] + for (const line of decode(await file.arrayBuffer()).split('\n')) { + const a = line.split('\t') + words.push(a[0]) + offsets.push(decodeBase64Number(a[1])) + sizes.push(decodeBase64Number(a[2])) + } + this.words = words + this.offsets = offsets + this.sizes = sizes + } +} + +export class DictdDict { + #dict = new DictZip() + #idx = new DictdIndex() + loadDict(file, inflate) { + this.#dict.inflate = inflate + return this.#dict.load(file) + } + async #readWord(i) { + const word = this.#idx.getWord(i) + const offset = this.#idx.offsets[i] + const size = this.#idx.sizes[i] + return { word, data: ['m', this.#dict.read(offset, size)] } + } + #readWords(arr) { + return Promise.all(arr.map(this.#readWord.bind(this))) + } + lookup(query) { + return this.#readWords(this.#idx.lookup(query)) + } +} + +class StarDictIndex extends Index { + isSyn + #arr + getWord(i) { + const word = this.words[i] + if (!word) return + return decode(this.#arr.subarray(word[0], word[1])) + } + async load(file) { + const { isSyn } = this + const buf = await file.arrayBuffer() + const arr = new Uint8Array(buf) + this.#arr = arr + const view = new DataView(buf) + const words = [] + const offsets = [] + const sizes = [] + for (let i = 0; i < arr.length;) { + const newI = arr.subarray(0, i + 256).indexOf(0, i) + if (newI < 0) throw new Error('Word too big') + words.push([i, newI]) + offsets.push(view.getUint32(newI + 1)) + if (isSyn) i = newI + 5 + else { + sizes.push(view.getUint32(newI + 5)) + i = newI + 9 + } + } + this.words = words + this.offsets = offsets + this.sizes = sizes + } +} + +export class StarDict { + #dict = new DictZip() + #idx = new StarDictIndex() + #syn = Object.assign(new StarDictIndex(), { isSyn: true }) + async loadIfo(file) { + const str = decode(await file.arrayBuffer()) + this.ifo = Object.fromEntries(str.split('\n').map(line => { + const sep = line.indexOf('=') + if (sep < 0) return + return [line.slice(0, sep), line.slice(sep + 1)] + }).filter(x => x)) + } + loadDict(file, inflate) { + this.#dict.inflate = inflate + return this.#dict.load(file) + } + loadIdx(file) { + return this.#idx.load(file) + } + loadSyn(file) { + if (file) return this.#syn.load(file) + } + async #readWord(i) { + const word = this.#idx.getWord(i) + const offset = this.#idx.offsets[i] + const size = this.#idx.sizes[i] + const data = await this.#dict.read(offset, size) + const seq = this.ifo.sametypesequence + if (!seq) throw new Error('TODO') + if (seq.length === 1) return { word, data: [[seq[0], data]] } + throw new Error('TODO') + } + #readWords(arr) { + return Promise.all(arr.map(this.#readWord.bind(this))) + } + lookup(query) { + return this.#readWords(this.#idx.lookup(query)) + } + synonyms(query) { + return this.#readWords(this.#syn.lookup(query).map(i => this.#syn.offsets[i])) + } +} diff --git a/static/vendor/foliate-js/epub.js b/static/vendor/foliate-js/epub.js new file mode 100644 index 0000000..e8bd703 --- /dev/null +++ b/static/vendor/foliate-js/epub.js @@ -0,0 +1,1083 @@ +import * as CFI from './epubcfi.js' + +const NS = { + CONTAINER: 'urn:oasis:names:tc:opendocument:xmlns:container', + XHTML: 'http://www.w3.org/1999/xhtml', + OPF: 'http://www.idpf.org/2007/opf', + EPUB: 'http://www.idpf.org/2007/ops', + DC: 'http://purl.org/dc/elements/1.1/', + DCTERMS: 'http://purl.org/dc/terms/', + ENC: 'http://www.w3.org/2001/04/xmlenc#', + NCX: 'http://www.daisy.org/z3986/2005/ncx/', + XLINK: 'http://www.w3.org/1999/xlink', + SMIL: 'http://www.w3.org/ns/SMIL', +} + +const MIME = { + XML: 'application/xml', + NCX: 'application/x-dtbncx+xml', + XHTML: 'application/xhtml+xml', + HTML: 'text/html', + CSS: 'text/css', + SVG: 'image/svg+xml', + JS: /\/(x-)?(javascript|ecmascript)/, +} + +// https://www.w3.org/TR/epub-33/#sec-reserved-prefixes +const PREFIX = { + a11y: 'http://www.idpf.org/epub/vocab/package/a11y/#', + dcterms: 'http://purl.org/dc/terms/', + marc: 'http://id.loc.gov/vocabulary/', + media: 'http://www.idpf.org/epub/vocab/overlays/#', + onix: 'http://www.editeur.org/ONIX/book/codelists/current.html#', + rendition: 'http://www.idpf.org/vocab/rendition/#', + schema: 'http://schema.org/', + xsd: 'http://www.w3.org/2001/XMLSchema#', + msv: 'http://www.idpf.org/epub/vocab/structure/magazine/#', + prism: 'http://www.prismstandard.org/specifications/3.0/PRISM_CV_Spec_3.0.htm#', +} + +const RELATORS = { + art: 'artist', + aut: 'author', + clr: 'colorist', + edt: 'editor', + ill: 'illustrator', + nrt: 'narrator', + trl: 'translator', + pbl: 'publisher', +} + +const ONIX5 = { + '02': 'isbn', + '06': 'doi', + '15': 'isbn', + '26': 'doi', + '34': 'issn', +} + +// convert to camel case +const camel = x => x.toLowerCase().replace(/[-:](.)/g, (_, g) => g.toUpperCase()) + +// strip and collapse ASCII whitespace +// https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace +const normalizeWhitespace = str => str ? str + .replace(/[\t\n\f\r ]+/g, ' ') + .replace(/^[\t\n\f\r ]+/, '') + .replace(/[\t\n\f\r ]+$/, '') : '' + +const filterAttribute = (attr, value, isList) => isList + ? el => el.getAttribute(attr)?.split(/\s/)?.includes(value) + : typeof value === 'function' + ? el => value(el.getAttribute(attr)) + : el => el.getAttribute(attr) === value + +const getAttributes = (...xs) => el => + el ? Object.fromEntries(xs.map(x => [camel(x), el.getAttribute(x)])) : null + +const getElementText = el => normalizeWhitespace(el?.textContent) + +const childGetter = (doc, ns) => { + // ignore the namespace if it doesn't appear in document at all + const useNS = doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns) + const f = useNS + ? (el, name) => el => el.namespaceURI === ns && el.localName === name + : (el, name) => el => el.localName === name + return { + $: (el, name) => [...el.children].find(f(el, name)), + $$: (el, name) => [...el.children].filter(f(el, name)), + $$$: useNS + ? (el, name) => [...el.getElementsByTagNameNS(ns, name)] + : (el, name) => [...el.getElementsByTagName(name)], + } +} + +const resolveURL = (url, relativeTo) => { + try { + if (relativeTo.includes(':')) return new URL(url, relativeTo) + // the base needs to be a valid URL, so set a base URL and then remove it + const root = 'https://invalid.invalid/' + const obj = new URL(url, root + relativeTo) + obj.search = '' + return decodeURI(obj.href.replace(root, '')) + } catch(e) { + console.warn(e) + return url + } +} + +const isExternal = uri => /^(?!blob)\w+:/i.test(uri) + +// like `path.relative()` in Node.js +const pathRelative = (from, to) => { + if (!from) return to + const as = from.replace(/\/$/, '').split('/') + const bs = to.replace(/\/$/, '').split('/') + const i = (as.length > bs.length ? as : bs).findIndex((_, i) => as[i] !== bs[i]) + return i < 0 ? '' : Array(as.length - i).fill('..').concat(bs.slice(i)).join('/') +} + +const pathDirname = str => str.slice(0, str.lastIndexOf('/') + 1) + +// replace asynchronously and sequentially +// same technique as https://stackoverflow.com/a/48032528 +const replaceSeries = async (str, regex, f) => { + const matches = [] + str.replace(regex, (...args) => (matches.push(args), null)) + const results = [] + for (const args of matches) results.push(await f(...args)) + return str.replace(regex, () => results.shift()) +} + +const regexEscape = str => str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + +const tidy = obj => { + for (const [key, val] of Object.entries(obj)) + if (val == null) delete obj[key] + else if (Array.isArray(val)) { + obj[key] = val.filter(x => x).map(x => + typeof x === 'object' && !Array.isArray(x) ? tidy(x) : x) + if (!obj[key].length) delete obj[key] + else if (obj[key].length === 1) obj[key] = obj[key][0] + } + else if (typeof val === 'object') { + obj[key] = tidy(val) + if (!Object.keys(val).length) delete obj[key] + } + const keys = Object.keys(obj) + if (keys.length === 1 && keys[0] === 'name') return obj[keys[0]] + return obj +} + +// https://www.w3.org/TR/epub/#sec-prefix-attr +const getPrefixes = doc => { + const map = new Map(Object.entries(PREFIX)) + const value = doc.documentElement.getAttributeNS(NS.EPUB, 'prefix') + || doc.documentElement.getAttribute('prefix') + if (value) for (const [, prefix, url] of value + .matchAll(/(.+): +(.+)[ \t\r\n]*/g)) map.set(prefix, url) + return map +} + +// https://www.w3.org/TR/epub-rs/#sec-property-values +// but ignoring the case where the prefix is omitted +const getPropertyURL = (value, prefixes) => { + if (!value) return null + const [a, b] = value.split(':') + const prefix = b ? a : null + const reference = b ? b : a + const baseURL = prefixes.get(prefix) + return baseURL ? baseURL + reference : null +} + +const getMetadata = opf => { + const { $ } = childGetter(opf, NS.OPF) + const $metadata = $(opf.documentElement, 'metadata') + + // first pass: convert to JS objects + const els = Object.groupBy($metadata.children, el => + el.namespaceURI === NS.DC ? 'dc' + : el.namespaceURI === NS.OPF && el.localName === 'meta' ? + (el.hasAttribute('name') ? 'legacyMeta' : 'meta') : '') + const baseLang = $metadata.getAttribute('xml:lang') + ?? opf.documentElement.getAttribute('xml:lang') ?? 'und' + const prefixes = getPrefixes(opf) + const parse = el => { + const property = el.getAttribute('property') + const scheme = el.getAttribute('scheme') + return { + property: getPropertyURL(property, prefixes) ?? property, + scheme: getPropertyURL(scheme, prefixes) ?? scheme, + lang: el.getAttribute('xml:lang'), + value: getElementText(el), + props: getProperties(el), + // `opf:` attributes from EPUB 2 & EPUB 3.1 (removed in EPUB 3.2) + attrs: Object.fromEntries(Array.from(el.attributes) + .filter(attr => attr.namespaceURI === NS.OPF) + .map(attr => [attr.localName, attr.value])), + } + } + const refines = Map.groupBy(els.meta ?? [], el => el.getAttribute('refines')) + const getProperties = el => { + const els = refines.get(el ? '#' + el.getAttribute('id') : null) + if (!els) return null + return Object.groupBy(els.map(parse), x => x.property) + } + const dc = Object.fromEntries(Object.entries(Object.groupBy(els.dc, el => el.localName)) + .map(([name, els]) => [name, els.map(parse)])) + const properties = getProperties() ?? {} + const legacyMeta = Object.fromEntries(els.legacyMeta?.map(el => + [el.getAttribute('name'), el.getAttribute('content')]) ?? []) + + // second pass: map to webpub + const one = x => x?.[0]?.value + const prop = (x, p) => one(x?.props?.[p]) + const makeLanguageMap = x => { + if (!x) return null + const alts = x.props?.['alternate-script'] ?? [] + const altRep = x.attrs['alt-rep'] + if (!alts.length && (!x.lang || x.lang === baseLang) && !altRep) return x.value + const map = { [x.lang ?? baseLang]: x.value } + if (altRep) map[x.attrs['alt-rep-lang']] = altRep + for (const y of alts) map[y.lang] ??= y.value + return map + } + const makeContributor = x => x ? ({ + name: makeLanguageMap(x), + sortAs: makeLanguageMap(x.props?.['file-as']?.[0]) ?? x.attrs['file-as'], + role: x.props?.role?.filter(x => x.scheme === PREFIX.marc + 'relators') + ?.map(x => x.value) ?? [x.attrs.role], + code: prop(x, 'term') ?? x.attrs.term, + scheme: prop(x, 'authority') ?? x.attrs.authority, + }) : null + const makeCollection = x => ({ + name: makeLanguageMap(x), + // NOTE: webpub requires number but EPUB allows values like "2.2.1" + position: one(x.props?.['group-position']), + }) + const makeAltIdentifier = x => { + const { value } = x + if (/^urn:/i.test(value)) return value + if (/^doi:/i.test(value)) return `urn:${value}` + const type = x.props?.['identifier-type'] + if (!type) { + const scheme = x.attrs.scheme + if (!scheme) return value + // https://idpf.github.io/epub-registries/identifiers/ + // but no "jdcn", which isn't a registered URN namespace + if (/^(doi|isbn|uuid)$/i.test(scheme)) return `urn:${scheme}:${value}` + // NOTE: webpub requires scheme to be a URI; EPUB allows anything + return { scheme, value } + } + if (type.scheme === PREFIX.onix + 'codelist5') { + const nid = ONIX5[type.value] + if (nid) return `urn:${nid}:${value}` + } + return value + } + const belongsTo = Object.groupBy(properties['belongs-to-collection'] ?? [], + x => prop(x, 'collection-type') === 'series' ? 'series' : 'collection') + const mainTitle = dc.title?.find(x => prop(x, 'title-type') === 'main') ?? dc.title?.[0] + const metadata = { + identifier: getIdentifier(opf), + title: makeLanguageMap(mainTitle), + sortAs: makeLanguageMap(mainTitle?.props?.['file-as']?.[0]) + ?? mainTitle?.attrs?.['file-as'] + ?? legacyMeta?.['calibre:title_sort'], + subtitle: dc.title?.find(x => prop(x, 'title-type') === 'subtitle')?.value, + language: dc.language?.map(x => x.value), + description: one(dc.description), + publisher: dc.publisher?.map(makeContributor), + published: dc.date?.find(x => x.attrs.event === 'publication')?.value + ?? one(dc.date), + modified: one(properties[PREFIX.dcterms + 'modified']) + ?? dc.date?.find(x => x.attrs.event === 'modification')?.value, + subject: dc.subject?.map(makeContributor), + belongsTo: { + collection: belongsTo.collection?.map(makeCollection), + series: belongsTo.series?.map(makeCollection) + ?? legacyMeta?.['calibre:series'] ? { + name: legacyMeta?.['calibre:series'], + position: parseFloat(legacyMeta?.['calibre:series_index']), + } : null, + }, + altIdentifier: dc.identifier?.map(makeAltIdentifier), + source: dc.source?.map(makeAltIdentifier), // NOTE: not in webpub schema + rights: one(dc.rights), // NOTE: not in webpub schema + pageBreakSource: one(properties['pageBreakSource']), // NOTE: not in webpub schema + } + const remapContributor = defaultKey => x => { + const keys = new Set(x.role?.map(role => RELATORS[role] ?? defaultKey)) + return [keys.size ? keys : [defaultKey], x] + } + for (const [keys, val] of [].concat( + dc.creator?.map(makeContributor)?.map(remapContributor('author')) ?? [], + dc.contributor?.map(makeContributor)?.map(remapContributor('contributor')) ?? [])) + for (const key of keys) + if (metadata[key]) metadata[key].push(val) + else metadata[key] = [val] + tidy(metadata) + if (metadata.altIdentifier === metadata.identifier) + delete metadata.altIdentifier + + const rendition = {} + const media = {} + for (const [key, val] of Object.entries(properties)) { + if (key.startsWith(PREFIX.rendition)) + rendition[camel(key.replace(PREFIX.rendition, ''))] = one(val) + else if (key.startsWith(PREFIX.media)) + media[camel(key.replace(PREFIX.media, ''))] = one(val) + } + if (media.duration) media.duration = parseClock(media.duration) + return { metadata, rendition, media } +} + +const parseNav = (doc, resolve = f => f) => { + const { $, $$, $$$ } = childGetter(doc, NS.XHTML) + const resolveHref = href => href ? decodeURI(resolve(href)) : null + const parseLI = getType => $li => { + const $a = $($li, 'a') ?? $($li, 'span') + const $ol = $($li, 'ol') + const href = resolveHref($a?.getAttribute('href')) + const label = getElementText($a) || $a?.getAttribute('title') + // TODO: get and concat alt/title texts in content + const result = { label, href, subitems: parseOL($ol) } + if (getType) result.type = $a?.getAttributeNS(NS.EPUB, 'type')?.split(/\s/) + return result + } + const parseOL = ($ol, getType) => $ol ? $$($ol, 'li').map(parseLI(getType)) : null + const parseNav = ($nav, getType) => parseOL($($nav, 'ol'), getType) + + const $$nav = $$$(doc, 'nav') + let toc = null, pageList = null, landmarks = null, others = [] + for (const $nav of $$nav) { + const type = $nav.getAttributeNS(NS.EPUB, 'type')?.split(/\s/) ?? [] + if (type.includes('toc')) toc ??= parseNav($nav) + else if (type.includes('page-list')) pageList ??= parseNav($nav) + else if (type.includes('landmarks')) landmarks ??= parseNav($nav, true) + else others.push({ + label: getElementText($nav.firstElementChild), type, + list: parseNav($nav), + }) + } + return { toc, pageList, landmarks, others } +} + +const parseNCX = (doc, resolve = f => f) => { + const { $, $$ } = childGetter(doc, NS.NCX) + const resolveHref = href => href ? decodeURI(resolve(href)) : null + const parseItem = el => { + const $label = $(el, 'navLabel') + const $content = $(el, 'content') + const label = getElementText($label) + const href = resolveHref($content.getAttribute('src')) + if (el.localName === 'navPoint') { + const els = $$(el, 'navPoint') + return { label, href, subitems: els.length ? els.map(parseItem) : null } + } + return { label, href } + } + const parseList = (el, itemName) => $$(el, itemName).map(parseItem) + const getSingle = (container, itemName) => { + const $container = $(doc.documentElement, container) + return $container ? parseList($container, itemName) : null + } + return { + toc: getSingle('navMap', 'navPoint'), + pageList: getSingle('pageList', 'pageTarget'), + others: $$(doc.documentElement, 'navList').map(el => ({ + label: getElementText($(el, 'navLabel')), + list: parseList(el, 'navTarget'), + })), + } +} + +const parseClock = str => { + if (!str) return + const parts = str.split(':').map(x => parseFloat(x)) + if (parts.length === 3) { + const [h, m, s] = parts + return h * 60 * 60 + m * 60 + s + } + if (parts.length === 2) { + const [m, s] = parts + return m * 60 + s + } + const [x, unit] = str.split(/(?=[^\d.])/) + const n = parseFloat(x) + const f = unit === 'h' ? 60 * 60 + : unit === 'min' ? 60 + : unit === 'ms' ? .001 + : 1 + return n * f +} + +class MediaOverlay extends EventTarget { + #entries + #lastMediaOverlayItem + #sectionIndex + #audioIndex + #itemIndex + #audio + #volume = 1 + #rate = 1 + #state + constructor(book, loadXML) { + super() + this.book = book + this.loadXML = loadXML + } + async #loadSMIL(item) { + if (this.#lastMediaOverlayItem === item) return + const doc = await this.loadXML(item.href) + const resolve = href => href ? resolveURL(href, item.href) : null + const { $, $$$ } = childGetter(doc, NS.SMIL) + this.#audioIndex = -1 + this.#itemIndex = -1 + this.#entries = $$$(doc, 'par').reduce((arr, $par) => { + const text = resolve($($par, 'text')?.getAttribute('src')) + const $audio = $($par, 'audio') + if (!text || !$audio) return arr + const src = resolve($audio.getAttribute('src')) + const begin = parseClock($audio.getAttribute('clipBegin')) + const end = parseClock($audio.getAttribute('clipEnd')) + const last = arr.at(-1) + if (last?.src === src) last.items.push({ text, begin, end }) + else arr.push({ src, items: [{ text, begin, end }] }) + return arr + }, []) + this.#lastMediaOverlayItem = item + } + get #activeAudio() { + return this.#entries[this.#audioIndex] + } + get #activeItem() { + return this.#activeAudio?.items?.[this.#itemIndex] + } + #error(e) { + console.error(e) + this.dispatchEvent(new CustomEvent('error', { detail: e })) + } + #highlight() { + this.dispatchEvent(new CustomEvent('highlight', { detail: this.#activeItem })) + } + #unhighlight() { + this.dispatchEvent(new CustomEvent('unhighlight', { detail: this.#activeItem })) + } + async #play(audioIndex, itemIndex) { + this.#stop() + this.#audioIndex = audioIndex + this.#itemIndex = itemIndex + const src = this.#activeAudio?.src + if (!src || !this.#activeItem) return this.start(this.#sectionIndex + 1) + + const url = URL.createObjectURL(await this.book.loadBlob(src)) + const audio = new Audio(url) + this.#audio = audio + audio.volume = this.#volume + audio.playbackRate = this.#rate + audio.addEventListener('timeupdate', () => { + if (audio.paused) return + const t = audio.currentTime + const { items } = this.#activeAudio + if (t > this.#activeItem?.end) { + this.#unhighlight() + if (this.#itemIndex === items.length - 1) { + this.#play(this.#audioIndex + 1, 0).catch(e => this.#error(e)) + return + } + } + const oldIndex = this.#itemIndex + while (items[this.#itemIndex + 1]?.begin <= t) this.#itemIndex++ + if (this.#itemIndex !== oldIndex) this.#highlight() + }) + audio.addEventListener('error', () => + this.#error(new Error(`Failed to load ${src}`))) + audio.addEventListener('playing', () => this.#highlight()) + audio.addEventListener('ended', () => { + this.#unhighlight() + URL.revokeObjectURL(url) + this.#audio = null + this.#play(audioIndex + 1, 0).catch(e => this.#error(e)) + }) + if (this.#state === 'paused') { + this.#highlight() + audio.currentTime = this.#activeItem.begin ?? 0 + } + else audio.addEventListener('canplaythrough', () => { + // for some reason need to seek in `canplaythrough` + // or it won't play when skipping in WebKit + audio.currentTime = this.#activeItem.begin ?? 0 + this.#state = 'playing' + audio.play().catch(e => this.#error(e)) + }, { once: true }) + } + async start(sectionIndex, filter = () => true) { + this.#audio?.pause() + const section = this.book.sections[sectionIndex] + const href = section?.id + if (!href) return + + const { mediaOverlay } = section + if (!mediaOverlay) return this.start(sectionIndex + 1) + this.#sectionIndex = sectionIndex + await this.#loadSMIL(mediaOverlay) + + for (let i = 0; i < this.#entries.length; i++) { + const { items } = this.#entries[i] + for (let j = 0; j < items.length; j++) { + if (items[j].text.split('#')[0] === href && filter(items[j], j, items)) + return this.#play(i, j).catch(e => this.#error(e)) + } + } + } + pause() { + this.#state = 'paused' + this.#audio?.pause() + } + resume() { + this.#state = 'playing' + this.#audio?.play().catch(e => this.#error(e)) + } + #stop() { + if (this.#audio) { + this.#audio.pause() + URL.revokeObjectURL(this.#audio.src) + this.#audio = null + this.#unhighlight() + } + } + stop() { + this.#state = 'stopped' + this.#stop() + } + prev() { + if (this.#itemIndex > 0) this.#play(this.#audioIndex, this.#itemIndex - 1) + else if (this.#audioIndex > 0) this.#play(this.#audioIndex - 1, + this.#entries[this.#audioIndex - 1].items.length - 1) + else if (this.#sectionIndex > 0) + this.start(this.#sectionIndex - 1, (_, i, items) => i === items.length - 1) + } + next() { + this.#play(this.#audioIndex, this.#itemIndex + 1) + } + setVolume(volume) { + this.#volume = volume + if (this.#audio) this.#audio.volume = volume + } + setRate(rate) { + this.#rate = rate + if (this.#audio) this.#audio.playbackRate = rate + } +} + +const isUUID = /([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})/ + +const getUUID = opf => { + for (const el of opf.getElementsByTagNameNS(NS.DC, 'identifier')) { + const [id] = getElementText(el).split(':').slice(-1) + if (isUUID.test(id)) return id + } + return '' +} + +const getIdentifier = opf => getElementText( + opf.getElementById(opf.documentElement.getAttribute('unique-identifier')) + ?? opf.getElementsByTagNameNS(NS.DC, 'identifier')[0]) + +// https://www.w3.org/publishing/epub32/epub-ocf.html#sec-resource-obfuscation +const deobfuscate = async (key, length, blob) => { + const array = new Uint8Array(await blob.slice(0, length).arrayBuffer()) + length = Math.min(length, array.length) + for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length] + return new Blob([array, blob.slice(length)], { type: blob.type }) +} + +const WebCryptoSHA1 = async str => { + const data = new TextEncoder().encode(str) + const buffer = await globalThis.crypto.subtle.digest('SHA-1', data) + return new Uint8Array(buffer) +} + +const deobfuscators = (sha1 = WebCryptoSHA1) => ({ + 'http://www.idpf.org/2008/embedding': { + key: opf => sha1(getIdentifier(opf) + // eslint-disable-next-line no-control-regex + .replaceAll(/[\u0020\u0009\u000d\u000a]/g, '')), + decode: (key, blob) => deobfuscate(key, 1040, blob), + }, + 'http://ns.adobe.com/pdf/enc#RC': { + key: opf => { + const uuid = getUUID(opf).replaceAll('-', '') + return Uint8Array.from({ length: 16 }, (_, i) => + parseInt(uuid.slice(i * 2, i * 2 + 2), 16)) + }, + decode: (key, blob) => deobfuscate(key, 1024, blob), + }, +}) + +class Encryption { + #uris = new Map() + #decoders = new Map() + #algorithms + constructor(algorithms) { + this.#algorithms = algorithms + } + async init(encryption, opf) { + if (!encryption) return + const data = Array.from( + encryption.getElementsByTagNameNS(NS.ENC, 'EncryptedData'), el => ({ + algorithm: el.getElementsByTagNameNS(NS.ENC, 'EncryptionMethod')[0] + ?.getAttribute('Algorithm'), + uri: el.getElementsByTagNameNS(NS.ENC, 'CipherReference')[0] + ?.getAttribute('URI'), + })) + for (const { algorithm, uri } of data) { + if (!this.#decoders.has(algorithm)) { + const algo = this.#algorithms[algorithm] + if (!algo) { + console.warn('Unknown encryption algorithm') + continue + } + const key = await algo.key(opf) + this.#decoders.set(algorithm, blob => algo.decode(key, blob)) + } + this.#uris.set(uri, algorithm) + } + } + getDecoder(uri) { + return this.#decoders.get(this.#uris.get(uri)) ?? (x => x) + } +} + +class Resources { + constructor({ opf, resolveHref }) { + this.opf = opf + const { $, $$, $$$ } = childGetter(opf, NS.OPF) + + const $manifest = $(opf.documentElement, 'manifest') + const $spine = $(opf.documentElement, 'spine') + const $$itemref = $$($spine, 'itemref') + + this.manifest = $$($manifest, 'item') + .map(getAttributes('href', 'id', 'media-type', 'properties', 'media-overlay')) + .map(item => { + item.href = resolveHref(item.href) + item.properties = item.properties?.split(/\s/) + return item + }) + this.manifestById = new Map(this.manifest.map(item => [item.id, item])) + this.spine = $$itemref + .map(getAttributes('idref', 'id', 'linear', 'properties')) + .map(item => (item.properties = item.properties?.split(/\s/), item)) + this.pageProgressionDirection = $spine + .getAttribute('page-progression-direction') + + this.navPath = this.getItemByProperty('nav')?.href + this.ncxPath = (this.getItemByID($spine.getAttribute('toc')) + ?? this.manifest.find(item => item.mediaType === MIME.NCX))?.href + + const $guide = $(opf.documentElement, 'guide') + if ($guide) this.guide = $$($guide, 'reference') + .map(getAttributes('type', 'title', 'href')) + .map(({ type, title, href }) => ({ + label: title, + type: type.split(/\s/), + href: resolveHref(href), + })) + + this.cover = this.getItemByProperty('cover-image') + // EPUB 2 compat + ?? this.getItemByID($$$(opf, 'meta') + .find(filterAttribute('name', 'cover')) + ?.getAttribute('content')) + ?? this.getItemByHref(this.guide + ?.find(ref => ref.type.includes('cover'))?.href) + + this.cfis = CFI.fromElements($$itemref) + } + getItemByID(id) { + return this.manifestById.get(id) + } + getItemByHref(href) { + return this.manifest.find(item => item.href === href) + } + getItemByProperty(prop) { + return this.manifest.find(item => item.properties?.includes(prop)) + } + resolveCFI(cfi) { + const parts = CFI.parse(cfi) + const top = (parts.parent ?? parts).shift() + let $itemref = CFI.toElement(this.opf, top) + // make sure it's an idref; if not, try again without the ID assertion + // mainly because Epub.js used to generate wrong ID assertions + // https://github.com/futurepress/epub.js/issues/1236 + if ($itemref && $itemref.nodeName !== 'idref') { + top.at(-1).id = null + $itemref = CFI.toElement(this.opf, top) + } + const idref = $itemref?.getAttribute('idref') + const index = this.spine.findIndex(item => item.idref === idref) + const anchor = doc => CFI.toRange(doc, parts) + return { index, anchor } + } +} + +class Loader { + #cache = new Map() + #children = new Map() + #refCount = new Map() + eventTarget = new EventTarget() + constructor({ loadText, loadBlob, resources }) { + this.loadText = loadText + this.loadBlob = loadBlob + this.manifest = resources.manifest + this.assets = resources.manifest + // needed only when replacing in (X)HTML w/o parsing (see below) + //.filter(({ mediaType }) => ![MIME.XHTML, MIME.HTML].includes(mediaType)) + } + async createURL(href, data, type, parent) { + if (!data) return '' + const detail = { data, type } + Object.defineProperty(detail, 'name', { value: href }) // readonly + const event = new CustomEvent('data', { detail }) + this.eventTarget.dispatchEvent(event) + const newData = await event.detail.data + const newType = await event.detail.type + const url = URL.createObjectURL(new Blob([newData], { type: newType })) + this.#cache.set(href, url) + this.#refCount.set(href, 1) + if (parent) { + const childList = this.#children.get(parent) + if (childList) childList.push(href) + else this.#children.set(parent, [href]) + } + return url + } + ref(href, parent) { + const childList = this.#children.get(parent) + if (!childList?.includes(href)) { + this.#refCount.set(href, this.#refCount.get(href) + 1) + //console.log(`referencing ${href}, now ${this.#refCount.get(href)}`) + if (childList) childList.push(href) + else this.#children.set(parent, [href]) + } + return this.#cache.get(href) + } + unref(href) { + if (!this.#refCount.has(href)) return + const count = this.#refCount.get(href) - 1 + //console.log(`unreferencing ${href}, now ${count}`) + if (count < 1) { + //console.log(`unloading ${href}`) + URL.revokeObjectURL(this.#cache.get(href)) + this.#cache.delete(href) + this.#refCount.delete(href) + // unref children + const childList = this.#children.get(href) + if (childList) while (childList.length) this.unref(childList.pop()) + this.#children.delete(href) + } else this.#refCount.set(href, count) + } + // load manifest item, recursively loading all resources as needed + async loadItem(item, parents = []) { + if (!item) return null + const { href, mediaType } = item + + const isScript = MIME.JS.test(item.mediaType) + const detail = { type: mediaType, isScript, allow: true} + const event = new CustomEvent('load', { detail }) + this.eventTarget.dispatchEvent(event) + const allow = await event.detail.allow + if (!allow) return null + + const parent = parents.at(-1) + if (this.#cache.has(href)) return this.ref(href, parent) + + const shouldReplace = + (isScript || [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(mediaType)) + // prevent circular references + && parents.every(p => p !== href) + if (shouldReplace) return this.loadReplaced(item, parents) + // NOTE: this can be replaced with `Promise.try()` + const tryLoadBlob = Promise.resolve().then(() => this.loadBlob(href)) + return this.createURL(href, tryLoadBlob, mediaType, parent) + } + async loadHref(href, base, parents = []) { + if (isExternal(href)) return href + const path = resolveURL(href, base) + const item = this.manifest.find(item => item.href === path) + if (!item) return href + return this.loadItem(item, parents.concat(base)) + } + async loadReplaced(item, parents = []) { + const { href, mediaType } = item + const parent = parents.at(-1) + let str = '' + try { + str = await this.loadText(href) + } catch (e) { + return this.createURL(href, Promise.reject(e), mediaType, parent) + } + if (!str) return null + + // note that one can also just use `replaceString` for everything: + // ``` + // const replaced = await this.replaceString(str, href, parents) + // return this.createURL(href, replaced, mediaType, parent) + // ``` + // which is basically what Epub.js does, which is simpler, but will + // break things like iframes (because you don't want to replace links) + // or text that just happen to be paths + + // parse and replace in HTML + if ([MIME.XHTML, MIME.HTML, MIME.SVG].includes(mediaType)) { + let doc = new DOMParser().parseFromString(str, mediaType) + // change to HTML if it's not valid XHTML + if (mediaType === MIME.XHTML && (doc.querySelector('parsererror') + || !doc.documentElement?.namespaceURI)) { + console.warn(doc.querySelector('parsererror')?.innerText ?? 'Invalid XHTML') + item.mediaType = MIME.HTML + doc = new DOMParser().parseFromString(str, item.mediaType) + } + // replace hrefs in XML processing instructions + // this is mainly for SVGs that use xml-stylesheet + if ([MIME.XHTML, MIME.SVG].includes(item.mediaType)) { + let child = doc.firstChild + while (child instanceof ProcessingInstruction) { + if (child.data) { + const replacedData = await replaceSeries(child.data, + /(?:^|\s*)(href\s*=\s*['"])([^'"]*)(['"])/i, + (_, p1, p2, p3) => this.loadHref(p2, href, parents) + .then(p2 => `${p1}${p2}${p3}`)) + child.replaceWith(doc.createProcessingInstruction( + child.target, replacedData)) + } + child = child.nextSibling + } + } + // replace hrefs (excluding anchors) + const replace = async (el, attr) => el.setAttribute(attr, + await this.loadHref(el.getAttribute(attr), href, parents)) + for (const el of doc.querySelectorAll('link[href]')) await replace(el, 'href') + for (const el of doc.querySelectorAll('[src]')) await replace(el, 'src') + for (const el of doc.querySelectorAll('[poster]')) await replace(el, 'poster') + for (const el of doc.querySelectorAll('object[data]')) await replace(el, 'data') + for (const el of doc.querySelectorAll('[*|href]:not([href])')) + el.setAttributeNS(NS.XLINK, 'href', await this.loadHref( + el.getAttributeNS(NS.XLINK, 'href'), href, parents)) + for (const el of doc.querySelectorAll('[srcset]')) + el.setAttribute('srcset', await replaceSeries(el.getAttribute('srcset'), + /(\s*)(.+?)\s*((?:\s[\d.]+[wx])+\s*(?:,|$)|,\s+|$)/g, + (_, p1, p2, p3) => this.loadHref(p2, href, parents) + .then(p2 => `${p1}${p2}${p3}`))) + // replace inline styles + for (const el of doc.querySelectorAll('style')) + if (el.textContent) el.textContent = + await this.replaceCSS(el.textContent, href, parents) + for (const el of doc.querySelectorAll('[style]')) + el.setAttribute('style', + await this.replaceCSS(el.getAttribute('style'), href, parents)) + // TODO: replace inline scripts? probably not worth the trouble + const result = new XMLSerializer().serializeToString(doc) + return this.createURL(href, result, item.mediaType, parent) + } + + const result = mediaType === MIME.CSS + ? await this.replaceCSS(str, href, parents) + : await this.replaceString(str, href, parents) + return this.createURL(href, result, mediaType, parent) + } + async replaceCSS(str, href, parents = []) { + const replacedUrls = await replaceSeries(str, + /url\(\s*["']?([^'"\n]*?)\s*["']?\s*\)/gi, + (_, url) => this.loadHref(url, href, parents) + .then(url => `url("${url}")`)) + // apart from `url()`, strings can be used for `@import` (but why?!) + return replaceSeries(replacedUrls, + /@import\s*["']([^"'\n]*?)["']/gi, + (_, url) => this.loadHref(url, href, parents) + .then(url => `@import "${url}"`)) + } + // find & replace all possible relative paths for all assets without parsing + replaceString(str, href, parents = []) { + const assetMap = new Map() + const urls = this.assets.map(asset => { + // do not replace references to the file itself + if (asset.href === href) return + // href was decoded and resolved when parsing the manifest + const relative = pathRelative(pathDirname(href), asset.href) + const relativeEnc = encodeURI(relative) + const rootRelative = '/' + asset.href + const rootRelativeEnc = encodeURI(rootRelative) + const set = new Set([relative, relativeEnc, rootRelative, rootRelativeEnc]) + for (const url of set) assetMap.set(url, asset) + return Array.from(set) + }).flat().filter(x => x) + if (!urls.length) return str + const regex = new RegExp(urls.map(regexEscape).join('|'), 'g') + return replaceSeries(str, regex, async match => + this.loadItem(assetMap.get(match.replace(/^\//, '')), + parents.concat(href))) + } + unloadItem(item) { + this.unref(item?.href) + } + destroy() { + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } +} + +const getHTMLFragment = (doc, id) => doc.getElementById(id) + ?? doc.querySelector(`[name="${CSS.escape(id)}"]`) + +const getPageSpread = properties => { + for (const p of properties) { + if (p === 'page-spread-left' || p === 'rendition:page-spread-left') + return 'left' + if (p === 'page-spread-right' || p === 'rendition:page-spread-right') + return 'right' + if (p === 'rendition:page-spread-center') return 'center' + } +} + +const getDisplayOptions = doc => { + if (!doc) return null + return { + fixedLayout: getElementText(doc.querySelector('option[name="fixed-layout"]')), + openToSpread: getElementText(doc.querySelector('option[name="open-to-spread"]')), + } +} + +export class EPUB { + parser = new DOMParser() + #loader + #encryption + constructor({ loadText, loadBlob, getSize, sha1 }) { + this.loadText = loadText + this.loadBlob = loadBlob + this.getSize = getSize + this.#encryption = new Encryption(deobfuscators(sha1)) + } + async #loadXML(uri) { + const str = await this.loadText(uri) + if (!str) return null + const doc = this.parser.parseFromString(str, MIME.XML) + if (doc.querySelector('parsererror')) + throw new Error(`XML parsing error: ${uri} +${doc.querySelector('parsererror').innerText}`) + return doc + } + async init() { + const $container = await this.#loadXML('META-INF/container.xml') + if (!$container) throw new Error('Failed to load container file') + + const opfs = Array.from( + $container.getElementsByTagNameNS(NS.CONTAINER, 'rootfile'), + getAttributes('full-path', 'media-type')) + .filter(file => file.mediaType === 'application/oebps-package+xml') + + if (!opfs.length) throw new Error('No package document defined in container') + const opfPath = opfs[0].fullPath + const opf = await this.#loadXML(opfPath) + if (!opf) throw new Error('Failed to load package document') + + const $encryption = await this.#loadXML('META-INF/encryption.xml') + await this.#encryption.init($encryption, opf) + + this.resources = new Resources({ + opf, + resolveHref: url => resolveURL(url, opfPath), + }) + this.#loader = new Loader({ + loadText: this.loadText, + loadBlob: uri => Promise.resolve(this.loadBlob(uri)) + .then(this.#encryption.getDecoder(uri)), + resources: this.resources, + }) + this.transformTarget = this.#loader.eventTarget + this.sections = this.resources.spine.map((spineItem, index) => { + const { idref, linear, properties = [] } = spineItem + const item = this.resources.getItemByID(idref) + if (!item) { + console.warn(`Could not find item with ID "${idref}" in manifest`) + return null + } + return { + id: item.href, + load: () => this.#loader.loadItem(item), + unload: () => this.#loader.unloadItem(item), + createDocument: () => this.loadDocument(item), + size: this.getSize(item.href), + cfi: this.resources.cfis[index], + linear, + pageSpread: getPageSpread(properties), + resolveHref: href => resolveURL(href, item.href), + mediaOverlay: item.mediaOverlay + ? this.resources.getItemByID(item.mediaOverlay) : null, + } + }).filter(s => s) + + const { navPath, ncxPath } = this.resources + if (navPath) try { + const resolve = url => resolveURL(url, navPath) + const nav = parseNav(await this.#loadXML(navPath), resolve) + this.toc = nav.toc + this.pageList = nav.pageList + this.landmarks = nav.landmarks + } catch(e) { + console.warn(e) + } + if (!this.toc && ncxPath) try { + const resolve = url => resolveURL(url, ncxPath) + const ncx = parseNCX(await this.#loadXML(ncxPath), resolve) + this.toc = ncx.toc + this.pageList = ncx.pageList + } catch(e) { + console.warn(e) + } + this.landmarks ??= this.resources.guide + + const { metadata, rendition, media } = getMetadata(opf) + this.metadata = metadata + this.rendition = rendition + this.media = media + this.dir = this.resources.pageProgressionDirection + const displayOptions = getDisplayOptions( + await this.#loadXML('META-INF/com.apple.ibooks.display-options.xml') + ?? await this.#loadXML('META-INF/com.kobobooks.display-options.xml')) + if (displayOptions) { + if (displayOptions.fixedLayout === 'true') + this.rendition.layout ??= 'pre-paginated' + if (displayOptions.openToSpread === 'false') this.sections + .find(section => section.linear !== 'no').pageSpread ??= + this.dir === 'rtl' ? 'left' : 'right' + } + return this + } + async loadDocument(item) { + const str = await this.loadText(item.href) + return this.parser.parseFromString(str, item.mediaType) + } + getMediaOverlay() { + return new MediaOverlay(this, this.#loadXML.bind(this)) + } + resolveCFI(cfi) { + return this.resources.resolveCFI(cfi) + } + resolveHref(href) { + const [path, hash] = href.split('#') + const item = this.resources.getItemByHref(decodeURI(path)) + if (!item) return null + const index = this.resources.spine.findIndex(({ idref }) => idref === item.id) + const anchor = hash ? doc => getHTMLFragment(doc, hash) : () => 0 + return { index, anchor } + } + splitTOCHref(href) { + return href?.split('#') ?? [] + } + getTOCFragment(doc, id) { + return doc.getElementById(id) + ?? doc.querySelector(`[name="${CSS.escape(id)}"]`) + } + isExternal(uri) { + return isExternal(uri) + } + async getCover() { + const cover = this.resources?.cover + return cover?.href + ? new Blob([await this.loadBlob(cover.href)], { type: cover.mediaType }) + : null + } + async getCalibreBookmarks() { + const txt = await this.loadText('META-INF/calibre_bookmarks.txt') + const magic = 'encoding=json+base64:' + if (txt?.startsWith(magic)) { + const json = atob(txt.slice(magic.length)) + return JSON.parse(json) + } + } + destroy() { + this.#loader?.destroy() + } +} diff --git a/static/vendor/foliate-js/epubcfi.js b/static/vendor/foliate-js/epubcfi.js new file mode 100644 index 0000000..712ead5 --- /dev/null +++ b/static/vendor/foliate-js/epubcfi.js @@ -0,0 +1,349 @@ +const findIndices = (arr, f) => arr + .map((x, i, a) => f(x, i, a) ? i : null).filter(x => x != null) +const splitAt = (arr, is) => [-1, ...is, arr.length].reduce(({ xs, a }, b) => + ({ xs: xs?.concat([arr.slice(a + 1, b)]) ?? [], a: b }), {}).xs +const concatArrays = (a, b) => + a.slice(0, -1).concat([a[a.length - 1].concat(b[0])]).concat(b.slice(1)) + +const isNumber = /\d/ +export const isCFI = /^epubcfi\((.*)\)$/ +const escapeCFI = str => str.replace(/[\^[\](),;=]/g, '^$&') + +const wrap = x => isCFI.test(x) ? x : `epubcfi(${x})` +const unwrap = x => x.match(isCFI)?.[1] ?? x +const lift = f => (...xs) => + `epubcfi(${f(...xs.map(x => x.match(isCFI)?.[1] ?? x))})` +export const joinIndir = lift((...xs) => xs.join('!')) + +const tokenizer = str => { + const tokens = [] + let state, escape, value = '' + const push = x => (tokens.push(x), state = null, value = '') + const cat = x => (value += x, escape = false) + for (const char of Array.from(str.trim()).concat('')) { + if (char === '^' && !escape) { + escape = true + continue + } + if (state === '!') push(['!']) + else if (state === ',') push([',']) + else if (state === '/' || state === ':') { + if (isNumber.test(char)) { + cat(char) + continue + } else push([state, parseInt(value)]) + } else if (state === '~') { + if (isNumber.test(char) || char === '.') { + cat(char) + continue + } else push(['~', parseFloat(value)]) + } else if (state === '@') { + if (char === ':') { + push(['@', parseFloat(value)]) + state = '@' + continue + } + if (isNumber.test(char) || char === '.') { + cat(char) + continue + } else push(['@', parseFloat(value)]) + } else if (state === '[') { + if (char === ';' && !escape) { + push(['[', value]) + state = ';' + } else if (char === ',' && !escape) { + push(['[', value]) + state = '[' + } else if (char === ']' && !escape) push(['[', value]) + else cat(char) + continue + } else if (state?.startsWith(';')) { + if (char === '=' && !escape) { + state = `;${value}` + value = '' + } else if (char === ';' && !escape) { + push([state, value]) + state = ';' + } else if (char === ']' && !escape) push([state, value]) + else cat(char) + continue + } + if (char === '/' || char === ':' || char === '~' || char === '@' + || char === '[' || char === '!' || char === ',') state = char + } + return tokens +} + +const findTokens = (tokens, x) => findIndices(tokens, ([t]) => t === x) + +const parser = tokens => { + const parts = [] + let state + for (const [type, val] of tokens) { + if (type === '/') parts.push({ index: val }) + else { + const last = parts[parts.length - 1] + if (type === ':') last.offset = val + else if (type === '~') last.temporal = val + else if (type === '@') last.spatial = (last.spatial ?? []).concat(val) + else if (type === ';s') last.side = val + else if (type === '[') { + if (state === '/' && val) last.id = val + else { + last.text = (last.text ?? []).concat(val) + continue + } + } + } + state = type + } + return parts +} + +// split at step indirections, then parse each part +const parserIndir = tokens => + splitAt(tokens, findTokens(tokens, '!')).map(parser) + +export const parse = cfi => { + const tokens = tokenizer(unwrap(cfi)) + const commas = findTokens(tokens, ',') + if (!commas.length) return parserIndir(tokens) + const [parent, start, end] = splitAt(tokens, commas).map(parserIndir) + return { parent, start, end } +} + +const partToString = ({ index, id, offset, temporal, spatial, text, side }) => { + const param = side ? `;s=${side}` : '' + return `/${index}` + + (id ? `[${escapeCFI(id)}${param}]` : '') + // "CFI expressions [..] SHOULD include an explicit character offset" + + (offset != null && index % 2 ? `:${offset}` : '') + + (temporal ? `~${temporal}` : '') + + (spatial ? `@${spatial.join(':')}` : '') + + (text || (!id && side) ? '[' + + (text?.map(escapeCFI)?.join(',') ?? '') + + param + ']' : '') +} + +const toInnerString = parsed => parsed.parent + ? [parsed.parent, parsed.start, parsed.end].map(toInnerString).join(',') + : parsed.map(parts => parts.map(partToString).join('')).join('!') + +const toString = parsed => wrap(toInnerString(parsed)) + +export const collapse = (x, toEnd) => typeof x === 'string' + ? toString(collapse(parse(x), toEnd)) + : x.parent ? concatArrays(x.parent, x[toEnd ? 'end' : 'start']) : x + +// create range CFI from two CFIs +const buildRange = (from, to) => { + if (typeof from === 'string') from = parse(from) + if (typeof to === 'string') to = parse(to) + from = collapse(from) + to = collapse(to, true) + // ranges across multiple documents are not allowed; handle local paths only + const localFrom = from[from.length - 1], localTo = to[to.length - 1] + const localParent = [], localStart = [], localEnd = [] + let pushToParent = true + const len = Math.max(localFrom.length, localTo.length) + for (let i = 0; i < len; i++) { + const a = localFrom[i], b = localTo[i] + pushToParent &&= a?.index === b?.index && !a?.offset && !b?.offset + if (pushToParent) localParent.push(a) + else { + if (a) localStart.push(a) + if (b) localEnd.push(b) + } + } + // copy non-local paths from `from` + const parent = from.slice(0, -1).concat([localParent]) + return toString({ parent, start: [localStart], end: [localEnd] }) +} + +export const compare = (a, b) => { + if (typeof a === 'string') a = parse(a) + if (typeof b === 'string') b = parse(b) + if (a.start || b.start) return compare(collapse(a), collapse(b)) + || compare(collapse(a, true), collapse(b, true)) + + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const p = a[i] ?? [], q = b[i] ?? [] + const maxIndex = Math.max(p.length, q.length) - 1 + for (let i = 0; i <= maxIndex; i++) { + const x = p[i], y = q[i] + if (!x) return -1 + if (!y) return 1 + if (x.index > y.index) return 1 + if (x.index < y.index) return -1 + if (i === maxIndex) { + // TODO: compare temporal & spatial offsets + if (x.offset > y.offset) return 1 + if (x.offset < y.offset) return -1 + } + } + } + return 0 +} + +const isTextNode = ({ nodeType }) => nodeType === 3 || nodeType === 4 +const isElementNode = ({ nodeType }) => nodeType === 1 + +const getChildNodes = (node, filter) => { + const nodes = Array.from(node.childNodes) + // "content other than element and character data is ignored" + .filter(node => isTextNode(node) || isElementNode(node)) + return filter ? nodes.map(node => { + const accept = filter(node) + if (accept === NodeFilter.FILTER_REJECT) return null + else if (accept === NodeFilter.FILTER_SKIP) return getChildNodes(node, filter) + else return node + }).flat().filter(x => x) : nodes +} + +// child nodes are organized such that the result is always +// [element, text, element, text, ..., element], +// regardless of the actual structure in the document; +// so multiple text nodes need to be combined, and nonexistent ones counted; +// see "Step Reference to Child Element or Character Data (/)" in EPUB CFI spec +const indexChildNodes = (node, filter) => { + const nodes = getChildNodes(node, filter) + .reduce((arr, node) => { + let last = arr[arr.length - 1] + if (!last) arr.push(node) + // "there is one chunk between each pair of child elements" + else if (isTextNode(node)) { + if (Array.isArray(last)) last.push(node) + else if (isTextNode(last)) arr[arr.length - 1] = [last, node] + else arr.push(node) + } else { + if (isElementNode(last)) arr.push(null, node) + else arr.push(node) + } + return arr + }, []) + // "the first chunk is located before the first child element" + if (isElementNode(nodes[0])) nodes.unshift('first') + // "the last chunk is located after the last child element" + if (isElementNode(nodes[nodes.length - 1])) nodes.push('last') + // "'virtual' elements" + nodes.unshift('before') // "0 is a valid index" + nodes.push('after') // "n+2 is a valid index" + return nodes +} + +const partsToNode = (node, parts, filter) => { + const { id } = parts[parts.length - 1] + if (id) { + const el = node.ownerDocument.getElementById(id) + if (el) return { node: el, offset: 0 } + } + for (const { index } of parts) { + const newNode = node ? indexChildNodes(node, filter)[index] : null + // handle non-existent nodes + if (newNode === 'first') return { node: node.firstChild ?? node } + if (newNode === 'last') return { node: node.lastChild ?? node } + if (newNode === 'before') return { node, before: true } + if (newNode === 'after') return { node, after: true } + node = newNode + } + const { offset } = parts[parts.length - 1] + if (!Array.isArray(node)) return { node, offset } + // get underlying text node and offset from the chunk + let sum = 0 + for (const n of node) { + const { length } = n.nodeValue + if (sum + length >= offset) return { node: n, offset: offset - sum } + sum += length + } +} + +const nodeToParts = (node, offset, filter) => { + let { id, parentNode } = node + while (filter && parentNode + && parentNode !== node.ownerDocument.documentElement + && filter(parentNode) === NodeFilter.FILTER_SKIP) + parentNode = parentNode.parentNode + const indexed = indexChildNodes(parentNode, filter) + const index = indexed.findIndex(x => + Array.isArray(x) ? x.some(x => x === node) : x === node) + // adjust offset as if merging the text nodes in the chunk + const chunk = indexed[index] + if (Array.isArray(chunk)) { + let sum = 0 + for (const x of chunk) { + if (x === node) { + sum += offset + break + } else sum += x.nodeValue.length + } + offset = sum + } + const part = { id, index, offset } + return (parentNode !== node.ownerDocument.documentElement + ? nodeToParts(parentNode, null, filter).concat(part) : [part]) + // remove ignored nodes + .filter(x => x.index !== -1) +} + +export const fromRange = (range, filter) => { + const { startContainer, startOffset, endContainer, endOffset } = range + const start = nodeToParts(startContainer, startOffset, filter) + if (range.collapsed) return toString([start]) + const end = nodeToParts(endContainer, endOffset, filter) + return buildRange([start], [end]) +} + +export const toRange = (doc, parts, filter) => { + const startParts = collapse(parts) + const endParts = collapse(parts, true) + + const root = doc.documentElement + const start = partsToNode(root, startParts[0], filter) + const end = partsToNode(root, endParts[0], filter) + + const range = doc.createRange() + + if (start.before) range.setStartBefore(start.node) + else if (start.after) range.setStartAfter(start.node) + else range.setStart(start.node, start.offset) + + if (end.before) range.setEndBefore(end.node) + else if (end.after) range.setEndAfter(end.node) + else range.setEnd(end.node, end.offset) + return range +} + +// faster way of getting CFIs for sorted elements in a single parent +export const fromElements = elements => { + const results = [] + const { parentNode } = elements[0] + const parts = nodeToParts(parentNode) + for (const [index, node] of indexChildNodes(parentNode).entries()) { + const el = elements[results.length] + if (node === el) + results.push(toString([parts.concat({ id: el.id, index })])) + } + return results +} + +export const toElement = (doc, parts) => + partsToNode(doc.documentElement, collapse(parts)).node + +// turn indices into standard CFIs when you don't have an actual package document +export const fake = { + fromIndex: index => wrap(`/6/${(index + 1) * 2}`), + toIndex: parts => parts?.at(-1).index / 2 - 1, +} + +// get CFI from Calibre bookmarks +// see https://github.com/johnfactotum/foliate/issues/849 +export const fromCalibrePos = pos => { + const [parts] = parse(pos) + const item = parts.shift() + parts.shift() + return toString([[{ index: 6 }, item], parts]) +} +export const fromCalibreHighlight = ({ spine_index, start_cfi, end_cfi }) => { + const pre = fake.fromIndex(spine_index) + '!' + return buildRange(pre + start_cfi.slice(2), pre + end_cfi.slice(2)) +} diff --git a/static/vendor/foliate-js/fb2.js b/static/vendor/foliate-js/fb2.js new file mode 100644 index 0000000..74784ad --- /dev/null +++ b/static/vendor/foliate-js/fb2.js @@ -0,0 +1,360 @@ +const normalizeWhitespace = str => str ? str + .replace(/[\t\n\f\r ]+/g, ' ') + .replace(/^[\t\n\f\r ]+/, '') + .replace(/[\t\n\f\r ]+$/, '') : '' +const getElementText = el => normalizeWhitespace(el?.textContent) + +const NS = { + XLINK: 'http://www.w3.org/1999/xlink', + EPUB: 'http://www.idpf.org/2007/ops', +} + +const MIME = { + XML: 'application/xml', + XHTML: 'application/xhtml+xml', +} + +const STYLE = { + 'strong': ['strong', 'self'], + 'emphasis': ['em', 'self'], + 'style': ['span', 'self'], + 'a': 'anchor', + 'strikethrough': ['s', 'self'], + 'sub': ['sub', 'self'], + 'sup': ['sup', 'self'], + 'code': ['code', 'self'], + 'image': 'image', +} + +const TABLE = { + 'tr': ['tr', { + 'th': ['th', STYLE, ['colspan', 'rowspan', 'align', 'valign']], + 'td': ['td', STYLE, ['colspan', 'rowspan', 'align', 'valign']], + }, ['align']], +} + +const POEM = { + 'epigraph': ['blockquote'], + 'subtitle': ['h2', STYLE], + 'text-author': ['p', STYLE], + 'date': ['p', STYLE], + 'stanza': 'stanza', +} + +const SECTION = { + 'title': ['header', { + 'p': ['h1', STYLE], + 'empty-line': ['br'], + }], + 'epigraph': ['blockquote', 'self'], + 'image': 'image', + 'annotation': ['aside'], + 'section': ['section', 'self'], + 'p': ['p', STYLE], + 'poem': ['blockquote', POEM], + 'subtitle': ['h2', STYLE], + 'cite': ['blockquote', 'self'], + 'empty-line': ['br'], + 'table': ['table', TABLE], + 'text-author': ['p', STYLE], +} +POEM['epigraph'].push(SECTION) + +const BODY = { + 'image': 'image', + 'title': ['section', { + 'p': ['h1', STYLE], + 'empty-line': ['br'], + }], + 'epigraph': ['section', SECTION], + 'section': ['section', SECTION], +} + +class FB2Converter { + constructor(fb2) { + this.fb2 = fb2 + this.doc = document.implementation.createDocument(NS.XHTML, 'html') + // use this instead of `getElementById` to allow images like + // `` + this.bins = new Map(Array.from(this.fb2.getElementsByTagName('binary'), + el => [el.id, el])) + } + getImageSrc(el) { + const href = el.getAttributeNS(NS.XLINK, 'href') + if (!href) return 'data:,' + const [, id] = href.split('#') + if (!id) return href + const bin = this.bins.get(id) + return bin + ? `data:${bin.getAttribute('content-type')};base64,${bin.textContent}` + : href + } + image(node) { + const el = this.doc.createElement('img') + el.alt = node.getAttribute('alt') + el.title = node.getAttribute('title') + el.setAttribute('src', this.getImageSrc(node)) + return el + } + anchor(node) { + const el = this.convert(node, { 'a': ['a', STYLE] }) + el.setAttribute('href', node.getAttributeNS(NS.XLINK, 'href')) + if (node.getAttribute('type') === 'note') + el.setAttributeNS(NS.EPUB, 'epub:type', 'noteref') + return el + } + stanza(node) { + const el = this.convert(node, { + 'stanza': ['p', { + 'title': ['header', { + 'p': ['strong', STYLE], + 'empty-line': ['br'], + }], + 'subtitle': ['p', STYLE], + }], + }) + for (const child of node.children) if (child.nodeName === 'v') { + el.append(this.doc.createTextNode(child.textContent)) + el.append(this.doc.createElement('br')) + } + return el + } + convert(node, def) { + // not an element; return text content + if (node.nodeType === 3) return this.doc.createTextNode(node.textContent) + if (node.nodeType === 4) return this.doc.createCDATASection(node.textContent) + if (node.nodeType === 8) return this.doc.createComment(node.textContent) + + const d = def?.[node.nodeName] + if (!d) return null + if (typeof d === 'string') return this[d](node) + + const [name, opts, attrs] = d + const el = this.doc.createElement(name) + + // copy the ID, and set class name from original element name + if (node.id) el.id = node.id + el.classList.add(node.nodeName) + + // copy attributes + if (Array.isArray(attrs)) for (const attr of attrs) { + const value = node.getAttribute(attr) + if (value) el.setAttribute(attr, value) + } + + // process child elements recursively + const childDef = opts === 'self' ? def : opts + let child = node.firstChild + while (child) { + const childEl = this.convert(child, childDef) + if (childEl) el.append(childEl) + child = child.nextSibling + } + return el + } +} + +const parseXML = async blob => { + const buffer = await blob.arrayBuffer() + const str = new TextDecoder('utf-8').decode(buffer) + const parser = new DOMParser() + const doc = parser.parseFromString(str, MIME.XML) + const encoding = doc.xmlEncoding + // `Document.xmlEncoding` is deprecated, and already removed in Firefox + // so parse the XML declaration manually + || str.match(/^<\?xml\s+version\s*=\s*["']1.\d+"\s+encoding\s*=\s*["']([A-Za-z0-9._-]*)["']/)?.[1] + if (encoding && encoding.toLowerCase() !== 'utf-8') { + const str = new TextDecoder(encoding).decode(buffer) + return parser.parseFromString(str, MIME.XML) + } + return doc +} + +const style = URL.createObjectURL(new Blob([` +@namespace epub "http://www.idpf.org/2007/ops"; +body > img, section > img { + display: block; + margin: auto; +} +.title h1 { + text-align: center; +} +body > section > .title, body.notesBodyType > .title { + margin: 3em 0; +} +body.notesBodyType > section .title h1 { + text-align: start; +} +body.notesBodyType > section .title { + margin: 1em 0; +} +p { + text-indent: 1em; + margin: 0; +} +:not(p) + p, p:first-child { + text-indent: 0; +} +.poem p { + text-indent: 0; + margin: 1em 0; +} +.text-author, .date { + text-align: end; +} +.text-author:before { + content: "—"; +} +table { + border-collapse: collapse; +} +td, th { + padding: .25em; +} +a[epub|type~="noteref"] { + font-size: .75em; + vertical-align: super; +} +body:not(.notesBodyType) > .title, body:not(.notesBodyType) > .epigraph { + margin: 3em 0; +} +`], { type: 'text/css' })) + +const template = html => ` + + + ${html} +` + +// name of custom ID attribute for TOC items +const dataID = 'data-foliate-id' + +export const makeFB2 = async blob => { + const book = {} + const doc = await parseXML(blob) + const converter = new FB2Converter(doc) + + const $ = x => doc.querySelector(x) + const $$ = x => [...doc.querySelectorAll(x)] + const getPerson = el => { + const nick = getElementText(el.querySelector('nickname')) + if (nick) return nick + const first = getElementText(el.querySelector('first-name')) + const middle = getElementText(el.querySelector('middle-name')) + const last = getElementText(el.querySelector('last-name')) + const name = [first, middle, last].filter(x => x).join(' ') + const sortAs = last + ? [last, [first, middle].filter(x => x).join(' ')].join(', ') + : null + return { name, sortAs } + } + const getDate = el => el?.getAttribute('value') ?? getElementText(el) + const annotation = $('title-info annotation') + book.metadata = { + title: getElementText($('title-info book-title')), + identifier: getElementText($('document-info id')), + language: getElementText($('title-info lang')), + author: $$('title-info author').map(getPerson), + translator: $$('title-info translator').map(getPerson), + contributor: $$('document-info author').map(getPerson) + // techincially the program probably shouldn't get the `bkp` role + // but it has been so used by calibre, so ¯\_(ツ)_/¯ + .concat($$('document-info program-used').map(getElementText)) + .map(x => Object.assign(typeof x === 'string' ? { name: x } : x, + { role: 'bkp' })), + publisher: getElementText($('publish-info publisher')), + published: getDate($('title-info date')), + modified: getDate($('document-info date')), + description: annotation ? converter.convert(annotation, + { annotation: ['div', SECTION] }).innerHTML : null, + subject: $$('title-info genre').map(getElementText), + } + if ($('coverpage image')) { + const src = converter.getImageSrc($('coverpage image')) + book.getCover = () => fetch(src).then(res => res.blob()) + } else book.getCover = () => null + + // get convert each body + const bodyData = Array.from(doc.querySelectorAll('body'), body => { + const converted = converter.convert(body, { body: ['body', BODY] }) + return [Array.from(converted.children, el => { + // get list of IDs in the section + const ids = [el, ...el.querySelectorAll('[id]')].map(el => el.id) + return { el, ids } + }), converted] + }) + + const urls = [] + const sectionData = bodyData[0][0] + // make a separate section for each section in the first body + .map(({ el, ids }) => { + // set up titles for TOC + const titles = Array.from( + el.querySelectorAll(':scope > section > .title'), + (el, index) => { + el.setAttribute(dataID, index) + return { title: getElementText(el), index } + }) + return { ids, titles, el } + }) + // for additional bodies, only make one section for each body + .concat(bodyData.slice(1).map(([sections, body]) => { + const ids = sections.map(s => s.ids).flat() + body.classList.add('notesBodyType') + return { ids, el: body, linear: 'no' } + })) + .map(({ ids, titles, el, linear }) => { + const str = template(el.outerHTML) + const blob = new Blob([str], { type: MIME.XHTML }) + const url = URL.createObjectURL(blob) + urls.push(url) + const title = normalizeWhitespace( + el.querySelector('.title, .subtitle, p')?.textContent + ?? (el.classList.contains('title') ? el.textContent : '')) + return { + ids, title, titles, load: () => url, + createDocument: () => new DOMParser().parseFromString(str, MIME.XHTML), + // doo't count image data as it'd skew the size too much + size: blob.size - Array.from(el.querySelectorAll('[src]'), + el => el.getAttribute('src')?.length ?? 0) + .reduce((a, b) => a + b, 0), + linear, + } + }) + + const idMap = new Map() + book.sections = sectionData.map((section, index) => { + const { ids, load, createDocument, size, linear } = section + for (const id of ids) if (id) idMap.set(id, index) + return { id: index, load, createDocument, size, linear } + }) + + book.toc = sectionData.map(({ title, titles }, index) => { + const id = index.toString() + return { + label: title, + href: id, + subitems: titles?.length ? titles.map(({ title, index }) => ({ + label: title, + href: `${id}#${index}`, + })) : null, + } + }).filter(item => item) + + book.resolveHref = href => { + const [a, b] = href.split('#') + return a + // the link is from the TOC + ? { index: Number(a), anchor: doc => doc.querySelector(`[${dataID}="${b}"]`) } + // link from within the page + : { index: idMap.get(b), anchor: doc => doc.getElementById(b) } + } + book.splitTOCHref = href => href?.split('#')?.map(x => Number(x)) ?? [] + book.getTOCFragment = (doc, id) => doc.querySelector(`[${dataID}="${id}"]`) + book.isExternal = uri => /^\w+:/i.test(uri) + + book.destroy = () => { + for (const url of urls) URL.revokeObjectURL(url) + } + return book +} diff --git a/static/vendor/foliate-js/fixed-layout.js b/static/vendor/foliate-js/fixed-layout.js new file mode 100644 index 0000000..c582aba --- /dev/null +++ b/static/vendor/foliate-js/fixed-layout.js @@ -0,0 +1,319 @@ +const parseViewport = str => str + ?.split(/[,;\s]/) // NOTE: technically, only the comma is valid + ?.filter(x => x) + ?.map(x => x.split('=').map(x => x.trim())) + +const getViewport = (doc, viewport) => { + // use `viewBox` for SVG + if (doc.documentElement.localName === 'svg') { + const [, , width, height] = doc.documentElement + .getAttribute('viewBox')?.split(/\s/) ?? [] + return { width, height } + } + + // get `viewport` `meta` element + const meta = parseViewport(doc.querySelector('meta[name="viewport"]') + ?.getAttribute('content')) + if (meta) return Object.fromEntries(meta) + + // fallback to book's viewport + if (typeof viewport === 'string') return parseViewport(viewport) + if (viewport?.width && viewport.height) return viewport + + // if no viewport (possibly with image directly in spine), get image size + const img = doc.querySelector('img') + if (img) return { width: img.naturalWidth, height: img.naturalHeight } + + // just show *something*, i guess... + console.warn(new Error('Missing viewport properties')) + return { width: 1000, height: 2000 } +} + +export class FixedLayout extends HTMLElement { + static observedAttributes = ['zoom'] + #root = this.attachShadow({ mode: 'closed' }) + #observer = new ResizeObserver(() => this.#render()) + #spreads + #index = -1 + defaultViewport + spread + #portrait = false + #left + #right + #center + #side + #zoom + constructor() { + super() + + const sheet = new CSSStyleSheet() + this.#root.adoptedStyleSheets = [sheet] + sheet.replaceSync(`:host { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + overflow: auto; + }`) + + this.#observer.observe(this) + } + attributeChangedCallback(name, _, value) { + switch (name) { + case 'zoom': + this.#zoom = value !== 'fit-width' && value !== 'fit-page' + ? parseFloat(value) : value + this.#render() + break + } + } + async #createFrame({ index, src: srcOption }) { + const srcOptionIsString = typeof srcOption === 'string' + const src = srcOptionIsString ? srcOption : srcOption?.src + const onZoom = srcOptionIsString ? null : srcOption?.onZoom + const element = document.createElement('div') + element.setAttribute('dir', 'ltr') + const iframe = document.createElement('iframe') + element.append(iframe) + Object.assign(iframe.style, { + border: '0', + display: 'none', + overflow: 'hidden', + }) + // `allow-scripts` is needed for events because of WebKit bug + // https://bugs.webkit.org/show_bug.cgi?id=218086 + iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') + iframe.setAttribute('scrolling', 'no') + iframe.setAttribute('part', 'filter') + this.#root.append(element) + if (!src) return { blank: true, element, iframe } + return new Promise(resolve => { + iframe.addEventListener('load', () => { + const doc = iframe.contentDocument + this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } })) + const { width, height } = getViewport(doc, this.defaultViewport) + resolve({ + element, iframe, + width: parseFloat(width), + height: parseFloat(height), + onZoom, + }) + }, { once: true }) + iframe.src = src + }) + } + #render(side = this.#side) { + if (!side) return + const left = this.#left ?? {} + const right = this.#center ?? this.#right ?? {} + const target = side === 'left' ? left : right + const { width, height } = this.getBoundingClientRect() + const portrait = this.spread !== 'both' && this.spread !== 'portrait' + && height > width + this.#portrait = portrait + const blankWidth = left.width ?? right.width ?? 0 + const blankHeight = left.height ?? right.height ?? 0 + + const scale = typeof this.#zoom === 'number' && !isNaN(this.#zoom) + ? this.#zoom + : (this.#zoom === 'fit-width' + ? (portrait || this.#center + ? width / (target.width ?? blankWidth) + : width / ((left.width ?? blankWidth) + (right.width ?? blankWidth))) + : (portrait || this.#center + ? Math.min( + width / (target.width ?? blankWidth), + height / (target.height ?? blankHeight)) + : Math.min( + width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)), + height / Math.max( + left.height ?? blankHeight, + right.height ?? blankHeight))) + ) || 1 + + const transform = frame => { + let { element, iframe, width, height, blank, onZoom } = frame + if (!iframe) return + if (onZoom) onZoom({ doc: frame.iframe.contentDocument, scale }) + const iframeScale = onZoom ? scale : 1 + Object.assign(iframe.style, { + width: `${width * iframeScale}px`, + height: `${height * iframeScale}px`, + transform: onZoom ? 'none' : `scale(${scale})`, + transformOrigin: 'top left', + display: blank ? 'none' : 'block', + }) + Object.assign(element.style, { + width: `${(width ?? blankWidth) * scale}px`, + height: `${(height ?? blankHeight) * scale}px`, + overflow: 'hidden', + display: 'block', + flexShrink: '0', + marginBlock: 'auto', + }) + if (portrait && frame !== target) { + element.style.display = 'none' + } + } + if (this.#center) { + transform(this.#center) + } else { + transform(left) + transform(right) + } + } + async #showSpread({ left, right, center, side }) { + this.#root.replaceChildren() + this.#left = null + this.#right = null + this.#center = null + if (center) { + this.#center = await this.#createFrame(center) + this.#side = 'center' + this.#render() + } else { + this.#left = await this.#createFrame(left) + this.#right = await this.#createFrame(right) + this.#side = this.#left.blank ? 'right' + : this.#right.blank ? 'left' : side + this.#render() + } + } + #goLeft() { + if (this.#center || this.#left?.blank) return + if (this.#portrait && this.#left?.element?.style?.display === 'none') { + this.#side = 'left' + this.#render() + this.#reportLocation('page') + return true + } + } + #goRight() { + if (this.#center || this.#right?.blank) return + if (this.#portrait && this.#right?.element?.style?.display === 'none') { + this.#side = 'right' + this.#render() + this.#reportLocation('page') + return true + } + } + open(book) { + this.book = book + const { rendition } = book + this.spread = rendition?.spread + this.defaultViewport = rendition?.viewport + + const rtl = book.dir === 'rtl' + const ltr = !rtl + this.rtl = rtl + + if (rendition?.spread === 'none') + this.#spreads = book.sections.map(section => ({ center: section })) + else this.#spreads = book.sections.reduce((arr, section, i) => { + const last = arr[arr.length - 1] + const { pageSpread } = section + const newSpread = () => { + const spread = {} + arr.push(spread) + return spread + } + if (pageSpread === 'center') { + const spread = last.left || last.right ? newSpread() : last + spread.center = section + } + else if (pageSpread === 'left') { + const spread = last.center || last.left || ltr && i ? newSpread() : last + spread.left = section + } + else if (pageSpread === 'right') { + const spread = last.center || last.right || rtl && i ? newSpread() : last + spread.right = section + } + else if (ltr) { + if (last.center || last.right) newSpread().left = section + else if (last.left || !i) last.right = section + else last.left = section + } + else { + if (last.center || last.left) newSpread().right = section + else if (last.right || !i) last.left = section + else last.right = section + } + return arr + }, [{}]) + } + get index() { + const spread = this.#spreads[this.#index] + const section = spread?.center ?? (this.#side === 'left' + ? spread.left ?? spread.right : spread.right ?? spread.left) + return this.book.sections.indexOf(section) + } + #reportLocation(reason) { + this.dispatchEvent(new CustomEvent('relocate', { detail: + { reason, range: null, index: this.index, fraction: 0, size: 1 } })) + } + getSpreadOf(section) { + const spreads = this.#spreads + for (let index = 0; index < spreads.length; index++) { + const { left, right, center } = spreads[index] + if (left === section) return { index, side: 'left' } + if (right === section) return { index, side: 'right' } + if (center === section) return { index, side: 'center' } + } + } + async goToSpread(index, side, reason) { + if (index < 0 || index > this.#spreads.length - 1) return + if (index === this.#index) { + this.#render(side) + return + } + this.#index = index + const spread = this.#spreads[index] + if (spread.center) { + const index = this.book.sections.indexOf(spread.center) + const src = await spread.center?.load?.() + await this.#showSpread({ center: { index, src } }) + } else { + const indexL = this.book.sections.indexOf(spread.left) + const indexR = this.book.sections.indexOf(spread.right) + const srcL = await spread.left?.load?.() + const srcR = await spread.right?.load?.() + const left = { index: indexL, src: srcL } + const right = { index: indexR, src: srcR } + await this.#showSpread({ left, right, side }) + } + this.#reportLocation(reason) + } + async select(target) { + await this.goTo(target) + // TODO + } + async goTo(target) { + const { book } = this + const resolved = await target + const section = book.sections[resolved.index] + if (!section) return + const { index, side } = this.getSpreadOf(section) + await this.goToSpread(index, side) + } + async next() { + const s = this.rtl ? this.#goLeft() : this.#goRight() + if (!s) return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left', 'page') + } + async prev() { + const s = this.rtl ? this.#goRight() : this.#goLeft() + if (!s) return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right', 'page') + } + getContents() { + return Array.from(this.#root.querySelectorAll('iframe'), frame => ({ + doc: frame.contentDocument, + // TODO: index, overlayer + })) + } + destroy() { + this.#observer.unobserve(this) + } +} + +customElements.define('foliate-fxl', FixedLayout) diff --git a/static/vendor/foliate-js/footnotes.js b/static/vendor/foliate-js/footnotes.js new file mode 100644 index 0000000..a1058cc --- /dev/null +++ b/static/vendor/foliate-js/footnotes.js @@ -0,0 +1,103 @@ +const getTypes = el => new Set(el?.getAttributeNS?.('http://www.idpf.org/2007/ops', 'type')?.split(' ')) +const getRoles = el => new Set(el?.getAttribute?.('role')?.split(' ')) + +const isSuper = el => { + if (el.matches('sup')) return true + const { verticalAlign } = getComputedStyle(el) + return verticalAlign === 'super' + || verticalAlign === 'top' + || verticalAlign === 'text-top' + || /^\d/.test(verticalAlign) +} + +const refTypes = ['biblioref', 'glossref', 'noteref'] +const refRoles = ['doc-biblioref', 'doc-glossref', 'doc-noteref'] +const isFootnoteReference = a => { + const types = getTypes(a) + const roles = getRoles(a) + return { + yes: refRoles.some(r => roles.has(r)) || refTypes.some(t => types.has(t)), + maybe: () => !types.has('backlink') && !roles.has('doc-backlink') + && (isSuper(a) || a.children.length === 1 && isSuper(a.children[0]) + || isSuper(a.parentElement)), + } +} + +const getReferencedType = el => { + const types = getTypes(el) + const roles = getRoles(el) + return roles.has('doc-biblioentry') || types.has('biblioentry') ? 'biblioentry' + : roles.has('definition') || types.has('glossdef') ? 'definition' + : roles.has('doc-endnote') || types.has('endnote') || types.has('rearnote') ? 'endnote' + : roles.has('doc-footnote') || types.has('footnote') ? 'footnote' + : roles.has('note') || types.has('note') ? 'note' : null +} + +const isInline = 'a, span, sup, sub, em, strong, i, b, small, big' +const extractFootnote = (doc, anchor) => { + let el = anchor(doc) + const target = el + while (el.matches(isInline)) { + const parent = el.parentElement + if (!parent) break + el = parent + } + if (el === doc.body) { + const sibling = target.nextElementSibling + if (sibling && !sibling.matches(isInline)) return sibling + throw new Error('Failed to extract footnote') + } + return el +} + +export class FootnoteHandler extends EventTarget { + detectFootnotes = true + #showFragment(book, { index, anchor }, href) { + const view = document.createElement('foliate-view') + return new Promise((resolve, reject) => { + view.addEventListener('load', e => { + try { + const { doc } = e.detail + const el = anchor(doc) + const type = getReferencedType(el) + const hidden = el?.matches?.('aside') && type === 'footnote' + if (el) { + const range = el.startContainer ? el : doc.createRange() + if (!el.startContainer) { + if (el.matches('li, aside')) range.selectNodeContents(el) + else range.selectNode(el) + } + const frag = range.extractContents() + doc.body.replaceChildren() + doc.body.appendChild(frag) + } + const detail = { view, href, type, hidden, target: el } + this.dispatchEvent(new CustomEvent('render', { detail })) + resolve() + } catch (e) { + reject(e) + } + }) + view.open(book) + .then(() => this.dispatchEvent(new CustomEvent('before-render', { detail: { view } }))) + .then(() => view.goTo(index)) + .catch(reject) + }) + } + handle(book, e) { + const { a, href } = e.detail + const { yes, maybe } = isFootnoteReference(a) + if (yes) { + e.preventDefault() + return Promise.resolve(book.resolveHref(href)).then(target => + this.#showFragment(book, target, href)) + } + else if (this.detectFootnotes && maybe()) { + e.preventDefault() + return Promise.resolve(book.resolveHref(href)).then(({ index, anchor }) => { + const target = { index, anchor: doc => extractFootnote(doc, anchor) } + return this.#showFragment(book, target, href) + }) + } + } +} diff --git a/static/vendor/foliate-js/mobi.js b/static/vendor/foliate-js/mobi.js new file mode 100644 index 0000000..20c77a8 --- /dev/null +++ b/static/vendor/foliate-js/mobi.js @@ -0,0 +1,1238 @@ +const unescapeHTML = str => { + if (!str) return '' + const textarea = document.createElement('textarea') + textarea.innerHTML = str + return textarea.value +} + +const MIME = { + XML: 'application/xml', + XHTML: 'application/xhtml+xml', + HTML: 'text/html', + CSS: 'text/css', + SVG: 'image/svg+xml', +} + +const PDB_HEADER = { + name: [0, 32, 'string'], + type: [60, 4, 'string'], + creator: [64, 4, 'string'], + numRecords: [76, 2, 'uint'], +} + +const PALMDOC_HEADER = { + compression: [0, 2, 'uint'], + numTextRecords: [8, 2, 'uint'], + recordSize: [10, 2, 'uint'], + encryption: [12, 2, 'uint'], +} + +const MOBI_HEADER = { + magic: [16, 4, 'string'], + length: [20, 4, 'uint'], + type: [24, 4, 'uint'], + encoding: [28, 4, 'uint'], + uid: [32, 4, 'uint'], + version: [36, 4, 'uint'], + titleOffset: [84, 4, 'uint'], + titleLength: [88, 4, 'uint'], + localeRegion: [94, 1, 'uint'], + localeLanguage: [95, 1, 'uint'], + resourceStart: [108, 4, 'uint'], + huffcdic: [112, 4, 'uint'], + numHuffcdic: [116, 4, 'uint'], + exthFlag: [128, 4, 'uint'], + trailingFlags: [240, 4, 'uint'], + indx: [244, 4, 'uint'], +} + +const KF8_HEADER = { + resourceStart: [108, 4, 'uint'], + fdst: [192, 4, 'uint'], + numFdst: [196, 4, 'uint'], + frag: [248, 4, 'uint'], + skel: [252, 4, 'uint'], + guide: [260, 4, 'uint'], +} + +const EXTH_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + count: [8, 4, 'uint'], +} + +const INDX_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + type: [8, 4, 'uint'], + idxt: [20, 4, 'uint'], + numRecords: [24, 4, 'uint'], + encoding: [28, 4, 'uint'], + language: [32, 4, 'uint'], + total: [36, 4, 'uint'], + ordt: [40, 4, 'uint'], + ligt: [44, 4, 'uint'], + numLigt: [48, 4, 'uint'], + numCncx: [52, 4, 'uint'], +} + +const TAGX_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + numControlBytes: [8, 4, 'uint'], +} + +const HUFF_HEADER = { + magic: [0, 4, 'string'], + offset1: [8, 4, 'uint'], + offset2: [12, 4, 'uint'], +} + +const CDIC_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + numEntries: [8, 4, 'uint'], + codeLength: [12, 4, 'uint'], +} + +const FDST_HEADER = { + magic: [0, 4, 'string'], + numEntries: [8, 4, 'uint'], +} + +const FONT_HEADER = { + flags: [8, 4, 'uint'], + dataStart: [12, 4, 'uint'], + keyLength: [16, 4, 'uint'], + keyStart: [20, 4, 'uint'], +} + +const MOBI_ENCODING = { + 1252: 'windows-1252', + 65001: 'utf-8', +} + +const EXTH_RECORD_TYPE = { + 100: ['creator', 'string', true], + 101: ['publisher'], + 103: ['description'], + 104: ['isbn'], + 105: ['subject', 'string', true], + 106: ['date'], + 108: ['contributor', 'string', true], + 109: ['rights'], + 110: ['subjectCode', 'string', true], + 112: ['source', 'string', true], + 113: ['asin'], + 121: ['boundary', 'uint'], + 122: ['fixedLayout'], + 125: ['numResources', 'uint'], + 126: ['originalResolution'], + 127: ['zeroGutter'], + 128: ['zeroMargin'], + 129: ['coverURI'], + 132: ['regionMagnification'], + 201: ['coverOffset', 'uint'], + 202: ['thumbnailOffset', 'uint'], + 503: ['title'], + 524: ['language', 'string', true], + 527: ['pageProgressionDirection'], +} + +const MOBI_LANG = { + 1: ['ar', 'ar-SA', 'ar-IQ', 'ar-EG', 'ar-LY', 'ar-DZ', 'ar-MA', 'ar-TN', 'ar-OM', + 'ar-YE', 'ar-SY', 'ar-JO', 'ar-LB', 'ar-KW', 'ar-AE', 'ar-BH', 'ar-QA'], + 2: ['bg'], 3: ['ca'], 4: ['zh', 'zh-TW', 'zh-CN', 'zh-HK', 'zh-SG'], 5: ['cs'], + 6: ['da'], 7: ['de', 'de-DE', 'de-CH', 'de-AT', 'de-LU', 'de-LI'], 8: ['el'], + 9: ['en', 'en-US', 'en-GB', 'en-AU', 'en-CA', 'en-NZ', 'en-IE', 'en-ZA', + 'en-JM', null, 'en-BZ', 'en-TT', 'en-ZW', 'en-PH'], + 10: ['es', 'es-ES', 'es-MX', null, 'es-GT', 'es-CR', 'es-PA', 'es-DO', + 'es-VE', 'es-CO', 'es-PE', 'es-AR', 'es-EC', 'es-CL', 'es-UY', 'es-PY', + 'es-BO', 'es-SV', 'es-HN', 'es-NI', 'es-PR'], + 11: ['fi'], 12: ['fr', 'fr-FR', 'fr-BE', 'fr-CA', 'fr-CH', 'fr-LU', 'fr-MC'], + 13: ['he'], 14: ['hu'], 15: ['is'], 16: ['it', 'it-IT', 'it-CH'], + 17: ['ja'], 18: ['ko'], 19: ['nl', 'nl-NL', 'nl-BE'], 20: ['no', 'nb', 'nn'], + 21: ['pl'], 22: ['pt', 'pt-BR', 'pt-PT'], 23: ['rm'], 24: ['ro'], 25: ['ru'], + 26: ['hr', null, 'sr'], 27: ['sk'], 28: ['sq'], 29: ['sv', 'sv-SE', 'sv-FI'], + 30: ['th'], 31: ['tr'], 32: ['ur'], 33: ['id'], 34: ['uk'], 35: ['be'], + 36: ['sl'], 37: ['et'], 38: ['lv'], 39: ['lt'], 41: ['fa'], 42: ['vi'], + 43: ['hy'], 44: ['az'], 45: ['eu'], 46: ['hsb'], 47: ['mk'], 48: ['st'], + 49: ['ts'], 50: ['tn'], 52: ['xh'], 53: ['zu'], 54: ['af'], 55: ['ka'], + 56: ['fo'], 57: ['hi'], 58: ['mt'], 59: ['se'], 62: ['ms'], 63: ['kk'], + 65: ['sw'], 67: ['uz', null, 'uz-UZ'], 68: ['tt'], 69: ['bn'], 70: ['pa'], + 71: ['gu'], 72: ['or'], 73: ['ta'], 74: ['te'], 75: ['kn'], 76: ['ml'], + 77: ['as'], 78: ['mr'], 79: ['sa'], 82: ['cy', 'cy-GB'], 83: ['gl', 'gl-ES'], + 87: ['kok'], 97: ['ne'], 98: ['fy'], +} + +const concatTypedArray = (a, b) => { + const result = new a.constructor(a.length + b.length) + result.set(a) + result.set(b, a.length) + return result +} +const concatTypedArray3 = (a, b, c) => { + const result = new a.constructor(a.length + b.length + c.length) + result.set(a) + result.set(b, a.length) + result.set(c, a.length + b.length) + return result +} + +const decoder = new TextDecoder() +const getString = buffer => decoder.decode(buffer) +const getUint = buffer => { + if (!buffer) return + const l = buffer.byteLength + const func = l === 4 ? 'getUint32' : l === 2 ? 'getUint16' : 'getUint8' + return new DataView(buffer)[func](0) +} +const getStruct = (def, buffer) => Object.fromEntries(Array.from(Object.entries(def)) + .map(([key, [start, len, type]]) => [key, + (type === 'string' ? getString : getUint)(buffer.slice(start, start + len))])) + +const getDecoder = x => new TextDecoder(MOBI_ENCODING[x]) + +const getVarLen = (byteArray, i = 0) => { + let value = 0, length = 0 + for (const byte of byteArray.subarray(i, i + 4)) { + value = (value << 7) | (byte & 0b111_1111) >>> 0 + length++ + if (byte & 0b1000_0000) break + } + return { value, length } +} + +// variable-length quantity, but read from the end of data +const getVarLenFromEnd = byteArray => { + let value = 0 + for (const byte of byteArray.subarray(-4)) { + // `byte & 0b1000_0000` indicates the start of value + if (byte & 0b1000_0000) value = 0 + value = (value << 7) | (byte & 0b111_1111) + } + return value +} + +const countBitsSet = x => { + let count = 0 + for (; x > 0; x = x >> 1) if ((x & 1) === 1) count++ + return count +} + +const countUnsetEnd = x => { + let count = 0 + while ((x & 1) === 0) x = x >> 1, count++ + return count +} + +const decompressPalmDOC = array => { + let output = [] + for (let i = 0; i < array.length; i++) { + const byte = array[i] + if (byte === 0) output.push(0) // uncompressed literal, just copy it + else if (byte <= 8) // copy next 1-8 bytes + for (const x of array.subarray(i + 1, (i += byte) + 1)) + output.push(x) + else if (byte <= 0b0111_1111) output.push(byte) // uncompressed literal + else if (byte <= 0b1011_1111) { + // 1st and 2nd bits are 10, meaning this is a length-distance pair + // read next byte and combine it with current byte + const bytes = (byte << 8) | array[i++ + 1] + // the 3rd to 13th bits encode distance + const distance = (bytes & 0b0011_1111_1111_1111) >>> 3 + // the last 3 bits, plus 3, is the length to copy + const length = (bytes & 0b111) + 3 + for (let j = 0; j < length; j++) + output.push(output[output.length - distance]) + } + // compressed from space plus char + else output.push(32, byte ^ 0b1000_0000) + } + return Uint8Array.from(output) +} + +const read32Bits = (byteArray, from) => { + const startByte = from >> 3 + const end = from + 32 + const endByte = end >> 3 + let bits = 0n + for (let i = startByte; i <= endByte; i++) + bits = bits << 8n | BigInt(byteArray[i] ?? 0) + return (bits >> (8n - BigInt(end & 7))) & 0xffffffffn +} + +const huffcdic = async (mobi, loadRecord) => { + const huffRecord = await loadRecord(mobi.huffcdic) + const { magic, offset1, offset2 } = getStruct(HUFF_HEADER, huffRecord) + if (magic !== 'HUFF') throw new Error('Invalid HUFF record') + + // table1 is indexed by byte value + const table1 = Array.from({ length: 256 }, (_, i) => offset1 + i * 4) + .map(offset => getUint(huffRecord.slice(offset, offset + 4))) + .map(x => [x & 0b1000_0000, x & 0b1_1111, x >>> 8]) + + // table2 is indexed by code length + const table2 = [null].concat(Array.from({ length: 32 }, (_, i) => offset2 + i * 8) + .map(offset => [ + getUint(huffRecord.slice(offset, offset + 4)), + getUint(huffRecord.slice(offset + 4, offset + 8))])) + + const dictionary = [] + for (let i = 1; i < mobi.numHuffcdic; i++) { + const record = await loadRecord(mobi.huffcdic + i) + const cdic = getStruct(CDIC_HEADER, record) + if (cdic.magic !== 'CDIC') throw new Error('Invalid CDIC record') + // `numEntries` is the total number of dictionary data across CDIC records + // so `n` here is the number of entries in *this* record + const n = Math.min(1 << cdic.codeLength, cdic.numEntries - dictionary.length) + const buffer = record.slice(cdic.length) + for (let i = 0; i < n; i++) { + const offset = getUint(buffer.slice(i * 2, i * 2 + 2)) + const x = getUint(buffer.slice(offset, offset + 2)) + const length = x & 0x7fff + const decompressed = x & 0x8000 + const value = new Uint8Array( + buffer.slice(offset + 2, offset + 2 + length)) + dictionary.push([value, decompressed]) + } + } + + const decompress = byteArray => { + let output = new Uint8Array() + const bitLength = byteArray.byteLength * 8 + for (let i = 0; i < bitLength;) { + const bits = Number(read32Bits(byteArray, i)) + let [found, codeLength, value] = table1[bits >>> 24] + if (!found) { + while (bits >>> (32 - codeLength) < table2[codeLength][0]) + codeLength += 1 + value = table2[codeLength][1] + } + if ((i += codeLength) > bitLength) break + + const code = value - (bits >>> (32 - codeLength)) + let [result, decompressed] = dictionary[code] + if (!decompressed) { + // the result is itself compressed + result = decompress(result) + // cache the result for next time + dictionary[code] = [result, true] + } + output = concatTypedArray(output, result) + } + return output + } + return decompress +} + +const getIndexData = async (indxIndex, loadRecord) => { + const indxRecord = await loadRecord(indxIndex) + const indx = getStruct(INDX_HEADER, indxRecord) + if (indx.magic !== 'INDX') throw new Error('Invalid INDX record') + const decoder = getDecoder(indx.encoding) + + const tagxBuffer = indxRecord.slice(indx.length) + const tagx = getStruct(TAGX_HEADER, tagxBuffer) + if (tagx.magic !== 'TAGX') throw new Error('Invalid TAGX section') + const numTags = (tagx.length - 12) / 4 + const tagTable = Array.from({ length: numTags }, (_, i) => + new Uint8Array(tagxBuffer.slice(12 + i * 4, 12 + i * 4 + 4))) + + const cncx = {} + let cncxRecordOffset = 0 + for (let i = 0; i < indx.numCncx; i++) { + const record = await loadRecord(indxIndex + indx.numRecords + i + 1) + const array = new Uint8Array(record) + for (let pos = 0; pos < array.byteLength;) { + const index = pos + const { value, length } = getVarLen(array, pos) + pos += length + const result = record.slice(pos, pos + value) + pos += value + cncx[cncxRecordOffset + index] = decoder.decode(result) + } + cncxRecordOffset += 0x10000 + } + + const table = [] + for (let i = 0; i < indx.numRecords; i++) { + const record = await loadRecord(indxIndex + 1 + i) + const array = new Uint8Array(record) + const indx = getStruct(INDX_HEADER, record) + if (indx.magic !== 'INDX') throw new Error('Invalid INDX record') + for (let j = 0; j < indx.numRecords; j++) { + const offsetOffset = indx.idxt + 4 + 2 * j + const offset = getUint(record.slice(offsetOffset, offsetOffset + 2)) + + const length = getUint(record.slice(offset, offset + 1)) + const name = getString(record.slice(offset + 1, offset + 1 + length)) + + const tags = [] + const startPos = offset + 1 + length + let controlByteIndex = 0 + let pos = startPos + tagx.numControlBytes + for (const [tag, numValues, mask, end] of tagTable) { + if (end & 1) { + controlByteIndex++ + continue + } + const offset = startPos + controlByteIndex + const value = getUint(record.slice(offset, offset + 1)) & mask + if (value === mask) { + if (countBitsSet(mask) > 1) { + const { value, length } = getVarLen(array, pos) + tags.push([tag, null, value, numValues]) + pos += length + } else tags.push([tag, 1, null, numValues]) + } else tags.push([tag, value >> countUnsetEnd(mask), null, numValues]) + } + + const tagMap = {} + for (const [tag, valueCount, valueBytes, numValues] of tags) { + const values = [] + if (valueCount != null) { + for (let i = 0; i < valueCount * numValues; i++) { + const { value, length } = getVarLen(array, pos) + values.push(value) + pos += length + } + } else { + let count = 0 + while (count < valueBytes) { + const { value, length } = getVarLen(array, pos) + values.push(value) + pos += length + count += length + } + } + tagMap[tag] = values + } + table.push({ name, tagMap }) + } + } + return { table, cncx } +} + +const getNCX = async (indxIndex, loadRecord) => { + const { table, cncx } = await getIndexData(indxIndex, loadRecord) + const items = table.map(({ tagMap }, index) => ({ + index, + offset: tagMap[1]?.[0], + size: tagMap[2]?.[0], + label: cncx[tagMap[3]] ?? '', + headingLevel: tagMap[4]?.[0], + pos: tagMap[6], + parent: tagMap[21]?.[0], + firstChild: tagMap[22]?.[0], + lastChild: tagMap[23]?.[0], + })) + const getChildren = item => { + if (item.firstChild == null) return item + item.children = items.filter(x => x.parent === item.index).map(getChildren) + return item + } + return items.filter(item => item.headingLevel === 0).map(getChildren) +} + +const getEXTH = (buf, encoding) => { + const { magic, count } = getStruct(EXTH_HEADER, buf) + if (magic !== 'EXTH') throw new Error('Invalid EXTH header') + const decoder = getDecoder(encoding) + const results = {} + let offset = 12 + for (let i = 0; i < count; i++) { + const type = getUint(buf.slice(offset, offset + 4)) + const length = getUint(buf.slice(offset + 4, offset + 8)) + if (type in EXTH_RECORD_TYPE) { + const [name, typ, many] = EXTH_RECORD_TYPE[type] + const data = buf.slice(offset + 8, offset + length) + const value = typ === 'uint' ? getUint(data) : decoder.decode(data) + if (many) { + results[name] ??= [] + results[name].push(value) + } else results[name] = value + } + offset += length + } + return results +} + +const getFont = async (buf, unzlib) => { + const { flags, dataStart, keyLength, keyStart } = getStruct(FONT_HEADER, buf) + const array = new Uint8Array(buf.slice(dataStart)) + // deobfuscate font + if (flags & 0b10) { + const bytes = keyLength === 16 ? 1024 : 1040 + const key = new Uint8Array(buf.slice(keyStart, keyStart + keyLength)) + const length = Math.min(bytes, array.length) + for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length] + } + // decompress font + if (flags & 1) try { + return await unzlib(array) + } catch (e) { + console.warn(e) + console.warn('Failed to decompress font') + } + return array +} + +export const isMOBI = async file => { + const magic = getString(await file.slice(60, 68).arrayBuffer()) + return magic === 'BOOKMOBI'// || magic === 'TEXtREAd' +} + +class PDB { + #file + #offsets + pdb + async open(file) { + this.#file = file + const pdb = getStruct(PDB_HEADER, await file.slice(0, 78).arrayBuffer()) + this.pdb = pdb + const buffer = await file.slice(78, 78 + pdb.numRecords * 8).arrayBuffer() + // get start and end offsets for each record + this.#offsets = Array.from({ length: pdb.numRecords }, + (_, i) => getUint(buffer.slice(i * 8, i * 8 + 4))) + .map((x, i, a) => [x, a[i + 1]]) + } + loadRecord(index) { + const offsets = this.#offsets[index] + if (!offsets) throw new RangeError('Record index out of bounds') + return this.#file.slice(...offsets).arrayBuffer() + } + async loadMagic(index) { + const start = this.#offsets[index][0] + return getString(await this.#file.slice(start, start + 4).arrayBuffer()) + } +} + +export class MOBI extends PDB { + #start = 0 + #resourceStart + #decoder + #encoder + #decompress + #removeTrailingEntries + constructor({ unzlib }) { + super() + this.unzlib = unzlib + } + async open(file) { + await super.open(file) + // TODO: if (this.pdb.type === 'TEXt') + this.headers = this.#getHeaders(await super.loadRecord(0)) + this.#resourceStart = this.headers.mobi.resourceStart + let isKF8 = this.headers.mobi.version >= 8 + if (!isKF8) { + const boundary = this.headers.exth?.boundary + if (boundary < 0xffffffff) try { + // it's a "combo" MOBI/KF8 file; try to open the KF8 part + this.headers = this.#getHeaders(await super.loadRecord(boundary)) + this.#start = boundary + isKF8 = true + } catch (e) { + console.warn(e) + console.warn('Failed to open KF8; falling back to MOBI') + } + } + await this.#setup() + return isKF8 ? new KF8(this).init() : new MOBI6(this).init() + } + #getHeaders(buf) { + const palmdoc = getStruct(PALMDOC_HEADER, buf) + const mobi = getStruct(MOBI_HEADER, buf) + if (mobi.magic !== 'MOBI') throw new Error('Missing MOBI header') + + const { titleOffset, titleLength, localeLanguage, localeRegion } = mobi + mobi.title = buf.slice(titleOffset, titleOffset + titleLength) + const lang = MOBI_LANG[localeLanguage] + mobi.language = lang?.[localeRegion >> 2] ?? lang?.[0] + + const exth = mobi.exthFlag & 0b100_0000 + ? getEXTH(buf.slice(mobi.length + 16), mobi.encoding) : null + const kf8 = mobi.version >= 8 ? getStruct(KF8_HEADER, buf) : null + return { palmdoc, mobi, exth, kf8 } + } + async #setup() { + const { palmdoc, mobi } = this.headers + this.#decoder = getDecoder(mobi.encoding) + // `TextEncoder` only supports UTF-8 + // we are only encoding ASCII anyway, so I think it's fine + this.#encoder = new TextEncoder() + + // set up decompressor + const { compression } = palmdoc + this.#decompress = compression === 1 ? f => f + : compression === 2 ? decompressPalmDOC + : compression === 17480 ? await huffcdic(mobi, this.loadRecord.bind(this)) + : null + if (!this.#decompress) throw new Error('Unknown compression type') + + // set up function for removing trailing bytes + const { trailingFlags } = mobi + const multibyte = trailingFlags & 1 + const numTrailingEntries = countBitsSet(trailingFlags >>> 1) + this.#removeTrailingEntries = array => { + for (let i = 0; i < numTrailingEntries; i++) { + const length = getVarLenFromEnd(array) + array = array.subarray(0, -length) + } + if (multibyte) { + const length = (array[array.length - 1] & 0b11) + 1 + array = array.subarray(0, -length) + } + return array + } + } + decode(...args) { + return this.#decoder.decode(...args) + } + encode(...args) { + return this.#encoder.encode(...args) + } + loadRecord(index) { + return super.loadRecord(this.#start + index) + } + loadMagic(index) { + return super.loadMagic(this.#start + index) + } + loadText(index) { + return this.loadRecord(index + 1) + .then(buf => new Uint8Array(buf)) + .then(this.#removeTrailingEntries) + .then(this.#decompress) + } + async loadResource(index) { + const buf = await super.loadRecord(this.#resourceStart + index) + const magic = getString(buf.slice(0, 4)) + if (magic === 'FONT') return getFont(buf, this.unzlib) + if (magic === 'VIDE' || magic === 'AUDI') return buf.slice(12) + return buf + } + getNCX() { + const index = this.headers.mobi.indx + if (index < 0xffffffff) return getNCX(index, this.loadRecord.bind(this)) + } + getMetadata() { + const { mobi, exth } = this.headers + return { + identifier: mobi.uid.toString(), + title: unescapeHTML(exth?.title || this.decode(mobi.title)), + author: exth?.creator?.map(unescapeHTML), + publisher: unescapeHTML(exth?.publisher), + language: exth?.language ?? mobi.language, + published: exth?.date, + description: unescapeHTML(exth?.description), + subject: exth?.subject?.map(unescapeHTML), + rights: unescapeHTML(exth?.rights), + contributor: exth?.contributor, + } + } + async getCover() { + const { exth } = this.headers + const offset = exth?.coverOffset < 0xffffffff ? exth?.coverOffset + : exth?.thumbnailOffset < 0xffffffff ? exth?.thumbnailOffset : null + if (offset != null) { + const buf = await this.loadResource(offset) + return new Blob([buf]) + } + } +} + +const mbpPagebreakRegex = /<\s*(?:mbp:)?pagebreak[^>]*>/gi +const fileposRegex = /<[^<>]+filepos=['"]{0,1}(\d+)[^<>]*>/gi + +const getIndent = el => { + let x = 0 + while (el) { + const parent = el.parentElement + if (parent) { + const tag = parent.tagName.toLowerCase() + if (tag === 'p') x += 1.5 + else if (tag === 'blockquote') x += 2 + } + el = parent + } + return x +} + +function rawBytesToString(uint8Array) { + const chunkSize = 0x8000 + let result = '' + for (let i = 0; i < uint8Array.length; i += chunkSize) { + result += String.fromCharCode.apply(null, uint8Array.subarray(i, i + chunkSize)) + } + return result +} + +class MOBI6 { + parser = new DOMParser() + serializer = new XMLSerializer() + #resourceCache = new Map() + #textCache = new Map() + #cache = new Map() + #sections + #fileposList = [] + #type = MIME.HTML + constructor(mobi) { + this.mobi = mobi + } + async init() { + const recordBuffers = [] + for (let i = 0; i < this.mobi.headers.palmdoc.numTextRecords; i++) { + const buf = await this.mobi.loadText(i) + recordBuffers.push(buf) + } + const totalLength = recordBuffers.reduce((sum, buf) => sum + buf.byteLength, 0) + // load all text records in an array + const array = new Uint8Array(totalLength) + recordBuffers.reduce((offset, buf) => { + array.set(new Uint8Array(buf), offset) + return offset + buf.byteLength + }, 0) + // convert to string so we can use regex + // note that `filepos` are byte offsets + // so it needs to preserve each byte as a separate character + // (see https://stackoverflow.com/q/50198017) + const str = rawBytesToString(array) + + // split content into sections at each `` + this.#sections = [0] + .concat(Array.from(str.matchAll(mbpPagebreakRegex), m => m.index)) + .map((start, i, a) => { + const end = a[i + 1] ?? array.length + return { book: this, raw: array.subarray(start, end) } + }) + // get start and end filepos for each section + .map((section, i, arr) => { + section.start = arr[i - 1]?.end ?? 0 + section.end = section.start + section.raw.byteLength + return section + }) + + this.sections = this.#sections.map((section, index) => ({ + id: index, + load: () => this.loadSection(section), + createDocument: () => this.createDocument(section), + size: section.end - section.start, + })) + + // get list of all `filepos` references in the book, + // which will be used to insert anchor elements + // because only then can they be referenced in the DOM + // NOTE: must be built BEFORE getGuide()/createDocument() so that sections + // cached during init() already have anchor elements inserted. + this.#fileposList = [...new Set( + Array.from(str.matchAll(fileposRegex), m => m[1]))] + .map(filepos => ({ filepos, number: Number(filepos) })) + .sort((a, b) => a.number - b.number) + + try { + this.landmarks = await this.getGuide() + const tocHref = this.landmarks + .find(({ type }) => type?.includes('toc'))?.href + if (tocHref) { + const { index } = this.resolveHref(tocHref) + const doc = await this.sections[index].createDocument() + let lastItem + let lastLevel = 0 + let lastIndent = 0 + const lastLevelOfIndent = new Map() + const lastParentOfLevel = new Map() + this.toc = Array.from(doc.querySelectorAll('a[filepos]')) + .reduce((arr, a) => { + const indent = getIndent(a) + const item = { + label: a.innerText?.trim() ?? '', + href: `filepos:${a.getAttribute('filepos')}`, + } + const level = indent > lastIndent ? lastLevel + 1 + : indent === lastIndent ? lastLevel + : lastLevelOfIndent.get(indent) ?? Math.max(0, lastLevel - 1) + if (level > lastLevel) { + if (lastItem) { + lastItem.subitems ??= [] + lastItem.subitems.push(item) + lastParentOfLevel.set(level, lastItem) + } + else arr.push(item) + } + else { + const parent = lastParentOfLevel.get(level) + if (parent) parent.subitems.push(item) + else arr.push(item) + } + lastItem = item + lastLevel = level + lastIndent = indent + lastLevelOfIndent.set(indent, level) + return arr + }, []) + } + } catch(e) { + console.warn(e) + } + + this.metadata = this.mobi.getMetadata() + this.getCover = this.mobi.getCover.bind(this.mobi) + return this + } + async getGuide() { + const doc = await this.createDocument(this.#sections[0]) + return Array.from(doc.getElementsByTagName('reference'), ref => ({ + label: ref.getAttribute('title'), + type: ref.getAttribute('type')?.split(/\s/), + href: `filepos:${ref.getAttribute('filepos')}`, + })) + } + async loadResource(index) { + if (this.#resourceCache.has(index)) return this.#resourceCache.get(index) + const raw = await this.mobi.loadResource(index) + const url = URL.createObjectURL(new Blob([raw])) + this.#resourceCache.set(index, url) + return url + } + async loadRecindex(recindex) { + return this.loadResource(Number(recindex) - 1) + } + async replaceResources(doc) { + for (const img of doc.querySelectorAll('img[recindex]')) { + const recindex = img.getAttribute('recindex') + try { + img.src = await this.loadRecindex(recindex) + } catch { + console.warn(`Failed to load image ${recindex}`) + } + } + for (const media of doc.querySelectorAll('[mediarecindex]')) { + const mediarecindex = media.getAttribute('mediarecindex') + const recindex = media.getAttribute('recindex') + try { + media.src = await this.loadRecindex(mediarecindex) + if (recindex) media.poster = await this.loadRecindex(recindex) + } catch { + console.warn(`Failed to load media ${mediarecindex}`) + } + } + for (const a of doc.querySelectorAll('[filepos]')) { + const filepos = a.getAttribute('filepos') + a.href = `filepos:${filepos}` + } + } + async loadText(section) { + if (this.#textCache.has(section)) return this.#textCache.get(section) + const { raw } = section + + // insert anchor elements for each `filepos` + const fileposList = this.#fileposList + .filter(({ number }) => number >= section.start && number < section.end) + .map(obj => ({ ...obj, offset: obj.number - section.start })) + let arr = raw + if (fileposList.length) { + arr = raw.subarray(0, fileposList[0].offset) + fileposList.forEach(({ filepos, offset }, i) => { + const next = fileposList[i + 1] + const a = this.mobi.encode(``) + arr = concatTypedArray3(arr, a, raw.subarray(offset, next?.offset)) + }) + } + const str = this.mobi.decode(arr).replaceAll(mbpPagebreakRegex, '') + this.#textCache.set(section, str) + return str + } + async createDocument(section) { + const str = await this.loadText(section) + return this.parser.parseFromString(str, this.#type) + } + async loadSection(section) { + if (this.#cache.has(section)) return this.#cache.get(section) + const doc = await this.createDocument(section) + + // inject default stylesheet + const style = doc.createElement('style') + doc.head.append(style) + // blockquotes in MOBI seem to have only a small left margin by default + // many books seem to rely on this, as it's the only way to set margin + // (since there's no CSS) + style.append(doc.createTextNode(`blockquote { + margin-block-start: 0; + margin-block-end: 0; + margin-inline-start: 1em; + margin-inline-end: 0; + }`)) + + await this.replaceResources(doc) + const result = this.serializer.serializeToString(doc) + const url = URL.createObjectURL(new Blob([result], { type: this.#type })) + this.#cache.set(section, url) + return url + } + resolveHref(href) { + const filepos = href.match(/filepos:(.*)/)[1] + const number = Number(filepos) + const index = this.#sections.findIndex(section => section.end > number) + const anchor = doc => doc.getElementById(`filepos${filepos}`) + return { index, anchor } + } + splitTOCHref(href) { + const filepos = href.match(/filepos:(.*)/)[1] + const number = Number(filepos) + const index = this.#sections.findIndex(section => section.end > number) + return [index, `filepos${filepos}`] + } + getTOCFragment(doc, id) { + return doc.getElementById(id) + } + isExternal(uri) { + return /^(?!blob|filepos)\w+:/i.test(uri) + } + destroy() { + for (const url of this.#resourceCache.values()) URL.revokeObjectURL(url) + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } +} + +// handlers for `kindle:` uris +const kindleResourceRegex = /kindle:(flow|embed):(\w+)(?:\?mime=(\w+\/[-+.\w]+))?/ +const kindlePosRegex = /kindle:pos:fid:(\w+):off:(\w+)/ +const parseResourceURI = str => { + const [resourceType, id, type] = str.match(kindleResourceRegex).slice(1) + return { resourceType, id: parseInt(id, 32), type } +} +const parsePosURI = str => { + const [fid, off] = str.match(kindlePosRegex).slice(1) + return { fid: parseInt(fid, 32), off: parseInt(off, 32) } +} +const makePosURI = (fid = 0, off = 0) => + `kindle:pos:fid:${fid.toString(32).toUpperCase().padStart(4, '0') + }:off:${off.toString(32).toUpperCase().padStart(10, '0')}` + +// `kindle:pos:` links are originally links that contain fragments identifiers +// so there should exist an element with `id` or `name` +// otherwise try to find one with an `aid` attribute +const getFragmentSelector = str => { + const match = str.match(/\s(id|name|aid)\s*=\s*['"]([^'"]*)['"]/i) + if (!match) return + const [, attr, value] = match + return `[${attr}="${CSS.escape(value)}"]` +} + +// replace asynchronously and sequentially +const replaceSeries = async (str, regex, f) => { + const matches = [] + str.replace(regex, (...args) => (matches.push(args), null)) + const results = [] + for (const args of matches) results.push(await f(...args)) + return str.replace(regex, () => results.shift()) +} + +const getPageSpread = properties => { + for (const p of properties) { + if (p === 'page-spread-left' || p === 'rendition:page-spread-left') + return 'left' + if (p === 'page-spread-right' || p === 'rendition:page-spread-right') + return 'right' + if (p === 'rendition:page-spread-center') return 'center' + } +} + +class KF8 { + parser = new DOMParser() + serializer = new XMLSerializer() + transformTarget = new EventTarget() + #cache = new Map() + #fragmentOffsets = new Map() + #fragmentSelectors = new Map() + #tables = {} + #sections + #fullRawLength + #rawHead = new Uint8Array() + #rawTail = new Uint8Array() + #lastLoadedHead = -1 + #lastLoadedTail = -1 + #type = MIME.XHTML + #inlineMap = new Map() + constructor(mobi) { + this.mobi = mobi + } + async init() { + const loadRecord = this.mobi.loadRecord.bind(this.mobi) + const { kf8 } = this.mobi.headers + + try { + const fdstBuffer = await loadRecord(kf8.fdst) + const fdst = getStruct(FDST_HEADER, fdstBuffer) + if (fdst.magic !== 'FDST') throw new Error('Missing FDST record') + const fdstTable = Array.from({ length: fdst.numEntries }, + (_, i) => 12 + i * 8) + .map(offset => [ + getUint(fdstBuffer.slice(offset, offset + 4)), + getUint(fdstBuffer.slice(offset + 4, offset + 8))]) + this.#tables.fdstTable = fdstTable + this.#fullRawLength = fdstTable[fdstTable.length - 1][1] + } catch {} + + const skelTable = (await getIndexData(kf8.skel, loadRecord)).table + .map(({ name, tagMap }, index) => ({ + index, name, + numFrag: tagMap[1][0], + offset: tagMap[6][0], + length: tagMap[6][1], + })) + const fragData = await getIndexData(kf8.frag, loadRecord) + const fragTable = fragData.table.map(({ name, tagMap }) => ({ + insertOffset: parseInt(name), + selector: fragData.cncx[tagMap[2][0]], + index: tagMap[4][0], + offset: tagMap[6][0], + length: tagMap[6][1], + })) + this.#tables.skelTable = skelTable + this.#tables.fragTable = fragTable + + this.#sections = skelTable.reduce((arr, skel) => { + const last = arr[arr.length - 1] + const fragStart = last?.fragEnd ?? 0, fragEnd = fragStart + skel.numFrag + const frags = fragTable.slice(fragStart, fragEnd) + const length = skel.length + frags.map(f => f.length).reduce((a, b) => a + b, 0) + const totalLength = (last?.totalLength ?? 0) + length + return arr.concat({ skel, frags, fragEnd, length, totalLength }) + }, []) + + const resources = await this.getResourcesByMagic(['RESC', 'PAGE']) + const pageSpreads = new Map() + if (resources.RESC) { + const buf = await this.mobi.loadRecord(resources.RESC) + const str = this.mobi.decode(buf.slice(16)).replace(/\0/g, '') + // the RESC record lacks the root `` element + // but seem to be otherwise valid XML + const index = str.search(/\?>/) + const xmlStr = `${str.slice(index)}` + const opf = this.parser.parseFromString(xmlStr, MIME.XML) + for (const $itemref of opf.querySelectorAll('spine > itemref')) { + const i = parseInt($itemref.getAttribute('skelid')) + pageSpreads.set(i, getPageSpread( + $itemref.getAttribute('properties')?.split(' ') ?? [])) + } + } + + this.sections = this.#sections.map((section, index) => + section.frags.length ? ({ + id: index, + load: () => this.loadSection(section), + createDocument: () => this.createDocument(section), + size: section.length, + pageSpread: pageSpreads.get(index), + }) : ({ linear: 'no' })) + + try { + const ncx = await this.mobi.getNCX() + const map = ({ label, pos, children }) => { + const [fid, off] = pos + const href = makePosURI(fid, off) + const arr = this.#fragmentOffsets.get(fid) + if (arr) arr.push(off) + else this.#fragmentOffsets.set(fid, [off]) + return { label: unescapeHTML(label), href, subitems: children?.map(map) } + } + this.toc = ncx?.map(map) + this.landmarks = await this.getGuide() + } catch(e) { + console.warn(e) + } + + const { exth } = this.mobi.headers + this.dir = exth.pageProgressionDirection + this.rendition = { + layout: exth.fixedLayout === 'true' ? 'pre-paginated' : 'reflowable', + viewport: Object.fromEntries(exth.originalResolution + ?.split('x')?.slice(0, 2) + ?.map((x, i) => [i ? 'height' : 'width', x]) ?? []), + } + + this.metadata = this.mobi.getMetadata() + this.getCover = this.mobi.getCover.bind(this.mobi) + return this + } + // is this really the only way of getting to RESC, PAGE, etc.? + async getResourcesByMagic(keys) { + const results = {} + const start = this.mobi.headers.kf8.resourceStart + const end = this.mobi.pdb.numRecords + for (let i = start; i < end; i++) { + try { + const magic = await this.mobi.loadMagic(i) + const match = keys.find(key => key === magic) + if (match) results[match] = i + } catch {} + } + return results + } + async getGuide() { + const index = this.mobi.headers.kf8.guide + if (index < 0xffffffff) { + const loadRecord = this.mobi.loadRecord.bind(this.mobi) + const { table, cncx } = await getIndexData(index, loadRecord) + return table.map(({ name, tagMap }) => ({ + label: cncx[tagMap[1][0]] ?? '', + type: name?.split(/\s/), + href: makePosURI(tagMap[6]?.[0] ?? tagMap[3]?.[0]), + })) + } + } + async loadResourceBlob(str) { + const { resourceType, id, type } = parseResourceURI(str) + const raw = resourceType === 'flow' ? await this.loadFlow(id) + : await this.mobi.loadResource(id - 1) + const result = [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(type) + ? await this.replaceResources(this.mobi.decode(raw)) : raw + const detail = { data: result, type } + const event = new CustomEvent('data', { detail }) + this.transformTarget.dispatchEvent(event) + const newData = await event.detail.data + const newType = await event.detail.type + const doc = newType === MIME.SVG ? this.parser.parseFromString(newData, newType) : null + return [new Blob([newData], { newType }), + // SVG wrappers need to be inlined + // as browsers don't allow external resources when loading SVG as an image + doc?.getElementsByTagNameNS('http://www.w3.org/2000/svg', 'image')?.length + ? doc.documentElement : null] + } + async loadResource(str) { + if (this.#cache.has(str)) return this.#cache.get(str) + const [blob, inline] = await this.loadResourceBlob(str) + const url = inline ? str : URL.createObjectURL(blob) + if (inline) this.#inlineMap.set(url, inline) + this.#cache.set(str, url) + return url + } + replaceResources(str) { + const regex = new RegExp(kindleResourceRegex, 'g') + return replaceSeries(str, regex, this.loadResource.bind(this)) + } + // NOTE: there doesn't seem to be a way to access text randomly? + // how to know the decompressed size of the records without decompressing? + // 4096 is just the maximum size + async loadRaw(start, end) { + // here we load either from the front or back until we have reached the + // required offsets; at worst you'd have to load half the book at once + const distanceHead = end - this.#rawHead.length + const distanceEnd = this.#fullRawLength == null ? Infinity + : (this.#fullRawLength - this.#rawTail.length) - start + // load from the start + if (distanceHead < 0 || distanceHead < distanceEnd) { + while (this.#rawHead.length < end) { + const index = ++this.#lastLoadedHead + const data = await this.mobi.loadText(index) + this.#rawHead = concatTypedArray(this.#rawHead, data) + } + return this.#rawHead.slice(start, end) + } + // load from the end + while (this.#fullRawLength - this.#rawTail.length > start) { + const index = this.mobi.headers.palmdoc.numTextRecords - 1 + - (++this.#lastLoadedTail) + const data = await this.mobi.loadText(index) + this.#rawTail = concatTypedArray(data, this.#rawTail) + } + const rawTailStart = this.#fullRawLength - this.#rawTail.length + return this.#rawTail.slice(start - rawTailStart, end - rawTailStart) + } + loadFlow(index) { + if (index < 0xffffffff) + return this.loadRaw(...this.#tables.fdstTable[index]) + } + async loadText(section) { + const { skel, frags, length } = section + const raw = await this.loadRaw(skel.offset, skel.offset + length) + let skeleton = raw.slice(0, skel.length) + for (const frag of frags) { + const insertOffset = frag.insertOffset - skel.offset + const offset = skel.length + frag.offset + const fragRaw = raw.slice(offset, offset + frag.length) + skeleton = concatTypedArray3( + skeleton.slice(0, insertOffset), fragRaw, + skeleton.slice(insertOffset)) + + const offsets = this.#fragmentOffsets.get(frag.index) + if (offsets) for (const offset of offsets) { + const str = this.mobi.decode(fragRaw.slice(offset)) + const selector = getFragmentSelector(str) + this.#setFragmentSelector(frag.index, offset, selector) + } + } + return this.mobi.decode(skeleton) + } + async createDocument(section) { + const str = await this.loadText(section) + return this.parser.parseFromString(str, this.#type) + } + async loadSection(section) { + if (this.#cache.has(section)) return this.#cache.get(section) + const str = await this.loadText(section) + const replaced = await this.replaceResources(str) + + // by default, type is XHTML; change to HTML if it's not valid XHTML + let doc = this.parser.parseFromString(replaced, this.#type) + if (doc.querySelector('parsererror') || !doc.documentElement?.namespaceURI) { + this.#type = MIME.HTML + doc = this.parser.parseFromString(replaced, this.#type) + } + for (const [url, node] of this.#inlineMap) { + for (const el of doc.querySelectorAll(`img[src="${url}"]`)) + el.replaceWith(node) + } + const url = URL.createObjectURL( + new Blob([this.serializer.serializeToString(doc)], { type: this.#type })) + this.#cache.set(section, url) + return url + } + getIndexByFID(fid) { + return this.#sections.findIndex(section => + section.frags.some(frag => frag.index === fid)) + } + #setFragmentSelector(id, offset, selector) { + const map = this.#fragmentSelectors.get(id) + if (map) map.set(offset, selector) + else { + const map = new Map() + this.#fragmentSelectors.set(id, map) + map.set(offset, selector) + } + } + async resolveHref(href) { + const { fid, off } = parsePosURI(href) + const index = this.getIndexByFID(fid) + if (index < 0) return + + const saved = this.#fragmentSelectors.get(fid)?.get(off) + if (saved) return { index, anchor: doc => doc.querySelector(saved) } + + const { skel, frags } = this.#sections[index] + const frag = frags.find(frag => frag.index === fid) + const offset = skel.offset + skel.length + frag.offset + const fragRaw = await this.loadRaw(offset, offset + frag.length) + const str = this.mobi.decode(fragRaw.slice(off)) + const selector = getFragmentSelector(str) + this.#setFragmentSelector(fid, off, selector) + const anchor = doc => doc.querySelector(selector) + return { index, anchor } + } + splitTOCHref(href) { + const pos = parsePosURI(href) + const index = this.getIndexByFID(pos.fid) + return [index, pos] + } + getTOCFragment(doc, { fid, off }) { + const selector = this.#fragmentSelectors.get(fid)?.get(off) + return doc.querySelector(selector) + } + isExternal(uri) { + return /^(?!blob|kindle)\w+:/i.test(uri) + } + destroy() { + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } +} diff --git a/static/vendor/foliate-js/opds.js b/static/vendor/foliate-js/opds.js new file mode 100644 index 0000000..53d5c00 --- /dev/null +++ b/static/vendor/foliate-js/opds.js @@ -0,0 +1,289 @@ +const NS = { + ATOM: 'http://www.w3.org/2005/Atom', + OPDS: 'http://opds-spec.org/2010/catalog', + THR: 'http://purl.org/syndication/thread/1.0', + DC: 'http://purl.org/dc/elements/1.1/', + DCTERMS: 'http://purl.org/dc/terms/', +} + +const MIME = { + ATOM: 'application/atom+xml', + OPDS2: 'application/opds+json', +} + +export const REL = { + ACQ: 'http://opds-spec.org/acquisition', + FACET: 'http://opds-spec.org/facet', + GROUP: 'http://opds-spec.org/group', + COVER: [ + 'http://opds-spec.org/image', + 'http://opds-spec.org/cover', // ManyBooks legacy, not in spec + 'x-stanza-cover-image', // Lexcycle Stanza legacy + ], + THUMBNAIL: [ + 'http://opds-spec.org/image/thumbnail', + 'http://opds-spec.org/thumbnail', // ManyBooks legacy, not in spec + 'x-stanza-cover-image-thumbnail', // Lexcycle Stanza legacy + ], +} + +export const SYMBOL = { + SUMMARY: Symbol('summary'), + CONTENT: Symbol('content'), +} + +const FACET_GROUP = Symbol('facetGroup') + +const groupByArray = (arr, f) => { + const map = new Map() + if (arr) for (const el of arr) { + const keys = f(el) + for (const key of [keys].flat()) { + const group = map.get(key) + if (group) group.push(el) + else map.set(key, [el]) + } + } + return map +} + +// https://www.rfc-editor.org/rfc/rfc7231#section-3.1.1 +const parseMediaType = str => { + if (!str) return null + const [mediaType, ...ps] = str.split(/ *; */) + return { + mediaType: mediaType.toLowerCase(), + parameters: Object.fromEntries(ps.map(p => { + const [name, val] = p.split('=') + return [name.toLowerCase(), val?.replace(/(^"|"$)/g, '')] + })), + } +} + +export const isOPDSCatalog = str => { + const parsed = parseMediaType(str) + if (!parsed) return false + const { mediaType, parameters } = parsed + if (mediaType === MIME.OPDS2) return true + return mediaType === MIME.ATOM && parameters.profile?.toLowerCase() === 'opds-catalog' +} + +// ignore the namespace if it doesn't appear in document at all +const useNS = (doc, ns) => + doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns) ? ns : null + +const filterNS = ns => ns + ? name => el => el.namespaceURI === ns && el.localName === name + : name => el => el.localName === name + +const getContent = el => { + if (!el) return + const type = el.getAttribute('type') ?? 'text' + const value = type === 'xhtml' ? el.innerHTML + : type === 'html' ? el.textContent + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + : el.textContent + return { value, type } +} + +const getTextContent = el => { + const content = getContent(el) + if (content?.type === 'text') return content?.value +} + +const getSummary = (a, b) => getTextContent(a) ?? getTextContent(b) + +const getPrice = link => { + const price = link.getElementsByTagNameNS(NS.OPDS, 'price')[0] + return price ? { + currency: price.getAttribute('currencycode'), + value: price.textContent, + } : null +} + +const getIndirectAcquisition = el => { + const ia = el.getElementsByTagNameNS(NS.OPDS, 'indirectAcquisition')[0] + if (!ia) return [] + return [{ type: ia.getAttribute('type') }, ...getIndirectAcquisition(ia)] +} + +const getLink = link => { + const obj = { + rel: link.getAttribute('rel')?.split(/ +/), + href: link.getAttribute('href'), + type: link.getAttribute('type'), + title: link.getAttribute('title'), + properties: { + price: getPrice(link), + indirectAcquisition: getIndirectAcquisition(link), + numberOfItems: link.getAttributeNS(NS.THR, 'count'), + }, + [FACET_GROUP]: link.getAttributeNS(NS.OPDS, 'facetGroup'), + } + if (link.getAttributeNS(NS.OPDS, 'activeFacet') === 'true') + obj.rel = [obj.rel ?? []].flat().concat('self') + return obj +} + +const getPerson = person => { + const NS = person.namespaceURI + const uri = person.getElementsByTagNameNS(NS, 'uri')[0]?.textContent + return { + name: person.getElementsByTagNameNS(NS, 'name')[0]?.textContent ?? '', + links: uri ? [{ href: uri }] : [], + } +} + +export const getPublication = entry => { + const filter = filterNS(useNS(entry.ownerDocument, NS.ATOM)) + const children = Array.from(entry.children) + const filterDCEL = filterNS(NS.DC) + const filterDCTERMS = filterNS(NS.DCTERMS) + const filterDC = x => { + const a = filterDCEL(x), b = filterDCTERMS(x) + return y => a(y) || b(y) + } + const links = children.filter(filter('link')).map(getLink) + const linksByRel = groupByArray(links, link => link.rel) + return { + metadata: { + title: children.find(filter('title'))?.textContent ?? '', + author: children.filter(filter('author')).map(getPerson), + contributor: children.filter(filter('contributor')).map(getPerson), + publisher: children.find(filterDC('publisher'))?.textContent, + published: (children.find(filterDCTERMS('issued')) + ?? children.find(filterDC('date')))?.textContent, + language: children.find(filterDC('language'))?.textContent, + identifier: children.find(filterDC('identifier'))?.textContent, + subject: children.filter(filter('category')).map(category => ({ + name: category.getAttribute('label'), + code: category.getAttribute('term'), + scheme: category.getAttribute('scheme'), + })), + rights: children.find(filter('rights'))?.textContent ?? '', + [SYMBOL.CONTENT]: getContent(children.find(filter('content')) + ?? children.find(filter('summary'))), + }, + links, + images: REL.COVER.concat(REL.THUMBNAIL) + .map(R => linksByRel.get(R)?.[0]).filter(x => x), + } +} + +export const getFeed = doc => { + const ns = useNS(doc, NS.ATOM) + const filter = filterNS(ns) + const children = Array.from(doc.documentElement.children) + const entries = children.filter(filter('entry')) + const links = children.filter(filter('link')).map(getLink) + const linksByRel = groupByArray(links, link => link.rel) + + const groupedItems = new Map([[null, []]]) + const groupLinkMap = new Map() + for (const entry of entries) { + const children = Array.from(entry.children) + const links = children.filter(filter('link')).map(getLink) + const linksByRel = groupByArray(links, link => link.rel) + const isPub = [...linksByRel.keys()] + .some(rel => rel?.startsWith(REL.ACQ) || rel === 'preview') + + const groupLinks = linksByRel.get(REL.GROUP) ?? linksByRel.get('collection') + const groupLink = groupLinks?.length + ? groupLinks.find(link => groupedItems.has(link.href)) ?? groupLinks[0] : null + if (groupLink && !groupLinkMap.has(groupLink.href)) + groupLinkMap.set(groupLink.href, groupLink) + + const item = isPub + ? getPublication(entry) + : Object.assign(links.find(link => isOPDSCatalog(link.type)) ?? links[0] ?? {}, { + title: children.find(filter('title'))?.textContent, + [SYMBOL.SUMMARY]: getSummary(children.find(filter('summary')), + children.find(filter('content'))), + }) + + const arr = groupedItems.get(groupLink?.href ?? null) + if (arr) arr.push(item) + else groupedItems.set(groupLink.href, [item]) + } + const [items, ...groups] = Array.from(groupedItems, ([key, items]) => { + const itemsKey = items[0]?.metadata ? 'publications' : 'navigation' + if (key == null) return { [itemsKey]: items } + const link = groupLinkMap.get(key) + return { + metadata: { + title: link.title, + numberOfItems: link.properties.numberOfItems, + }, + links: [{ rel: 'self', href: link.href, type: link.type }], + [itemsKey]: items, + } + }) + return { + metadata: { + title: children.find(filter('title'))?.textContent, + subtitle: children.find(filter('subtitle'))?.textContent, + }, + links, + ...items, + groups, + facets: Array.from( + groupByArray(linksByRel.get(REL.FACET) ?? [], link => link[FACET_GROUP]), + ([facet, links]) => ({ metadata: { title: facet }, links })), + } +} + +export const getSearch = async link => { + const { replace, getVariables } = await import('./uri-template.js') + return { + metadata: { + title: link.title, + }, + search: map => replace(link.href, map.get(null)), + params: Array.from(getVariables(link.href), name => ({ name })), + } +} + +export const getOpenSearch = doc => { + const defaultNS = doc.documentElement.namespaceURI + const filter = filterNS(defaultNS) + const children = Array.from(doc.documentElement.children) + + const $$urls = children.filter(filter('Url')) + const $url = $$urls.find(url => isOPDSCatalog(url.getAttribute('type'))) ?? $$urls[0] + if (!$url) throw new Error('document must contain at least one Url element') + + const regex = /{(?:([^}]+?):)?(.+?)(\?)?}/g + const defaultMap = new Map([ + ['count', '100'], + ['startIndex', $url.getAttribute('indexOffset') ?? '0'], + ['startPage', $url.getAttribute('pageOffset') ?? '0'], + ['language', '*'], + ['inputEncoding', 'UTF-8'], + ['outputEncoding', 'UTF-8'], + ]) + + const template = $url.getAttribute('template') + return { + metadata: { + title: (children.find(filter('LongName')) ?? children.find(filter('ShortName')))?.textContent, + description: children.find(filter('Description'))?.textContent, + }, + search: map => template.replace(regex, (_, prefix, param) => { + const namespace = prefix ? $url.lookupNamespaceURI(prefix) : null + const ns = namespace === defaultNS ? null : namespace + const val = map.get(ns)?.get(param) + return encodeURIComponent(val ? val : (!ns ? defaultMap.get(param) ?? '' : '')) + }), + params: Array.from(template.matchAll(regex), ([, prefix, param, optional]) => { + const namespace = prefix ? $url.lookupNamespaceURI(prefix) : null + const ns = namespace === defaultNS ? null : namespace + return { + ns, name: param, + required: !optional, + value: ns && ns !== defaultNS ? '' : defaultMap.get(param) ?? '', + } + }), + } +} diff --git a/static/vendor/foliate-js/overlayer.js b/static/vendor/foliate-js/overlayer.js new file mode 100644 index 0000000..6fd03ab --- /dev/null +++ b/static/vendor/foliate-js/overlayer.js @@ -0,0 +1,175 @@ +const createSVGElement = tag => + document.createElementNS('http://www.w3.org/2000/svg', tag) + +export class Overlayer { + #svg = createSVGElement('svg') + #map = new Map() + constructor() { + Object.assign(this.#svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + pointerEvents: 'none', + }) + } + get element() { + return this.#svg + } + add(key, range, draw, options) { + if (this.#map.has(key)) this.remove(key) + if (typeof range === 'function') range = range(this.#svg.getRootNode()) + const rects = range.getClientRects() + const element = draw(rects, options) + this.#svg.append(element) + this.#map.set(key, { range, draw, options, element, rects }) + } + remove(key) { + if (!this.#map.has(key)) return + this.#svg.removeChild(this.#map.get(key).element) + this.#map.delete(key) + } + redraw() { + for (const obj of this.#map.values()) { + const { range, draw, options, element } = obj + this.#svg.removeChild(element) + const rects = range.getClientRects() + const el = draw(rects, options) + this.#svg.append(el) + obj.element = el + obj.rects = rects + } + } + hitTest({ x, y }) { + const arr = Array.from(this.#map.entries()) + // loop in reverse to hit more recently added items first + for (let i = arr.length - 1; i >= 0; i--) { + const [key, obj] = arr[i] + for (const { left, top, right, bottom } of obj.rects) + if (top <= y && left <= x && bottom > y && right > x) + return [key, obj.range] + } + return [] + } + static underline(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, top, height } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', right - strokeWidth) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', strokeWidth) + g.append(el) + } + else for (const { left, bottom, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', bottom - strokeWidth) + el.setAttribute('height', strokeWidth) + el.setAttribute('width', width) + g.append(el) + } + return g + } + static strikethrough(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, left, top, height } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', (right + left) / 2) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', strokeWidth) + g.append(el) + } + else for (const { left, top, bottom, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', (top + bottom) / 2) + el.setAttribute('height', strokeWidth) + el.setAttribute('width', width) + g.append(el) + } + return g + } + static squiggly(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', 'none') + g.setAttribute('stroke', color) + g.setAttribute('stroke-width', strokeWidth) + const block = strokeWidth * 1.5 + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, top, height } of rects) { + const el = createSVGElement('path') + const n = Math.round(height / block / 1.5) + const inline = height / n + const ls = Array.from({ length: n }, + (_, i) => `l${i % 2 ? -block : block} ${inline}`).join('') + el.setAttribute('d', `M${right} ${top}${ls}`) + g.append(el) + } + else for (const { left, bottom, width } of rects) { + const el = createSVGElement('path') + const n = Math.round(width / block / 1.5) + const inline = width / n + const ls = Array.from({ length: n }, + (_, i) => `l${inline} ${i % 2 ? block : -block}`).join('') + el.setAttribute('d', `M${left} ${bottom}${ls}`) + g.append(el) + } + return g + } + static highlight(rects, options = {}) { + const { color = 'red' } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + g.style.opacity = 'var(--overlayer-highlight-opacity, .3)' + g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)' + for (const { left, top, height, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', width) + g.append(el) + } + return g + } + static outline(rects, options = {}) { + const { color = 'red', width: strokeWidth = 3, radius = 3 } = options + const g = createSVGElement('g') + g.setAttribute('fill', 'none') + g.setAttribute('stroke', color) + g.setAttribute('stroke-width', strokeWidth) + for (const { left, top, height, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', width) + el.setAttribute('rx', radius) + g.append(el) + } + return g + } + // make an exact copy of an image in the overlay + // one can then apply filters to the entire element, without affecting them; + // it's a bit silly and probably better to just invert images twice + // (though the color will be off in that case if you do heu-rotate) + static copyImage([rect], options = {}) { + const { src } = options + const image = createSVGElement('image') + const { left, top, height, width } = rect + image.setAttribute('href', src) + image.setAttribute('x', left) + image.setAttribute('y', top) + image.setAttribute('height', height) + image.setAttribute('width', width) + return image + } +} + diff --git a/static/vendor/foliate-js/paginator.js b/static/vendor/foliate-js/paginator.js new file mode 100644 index 0000000..7980c20 --- /dev/null +++ b/static/vendor/foliate-js/paginator.js @@ -0,0 +1,1130 @@ +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +const debounce = (f, wait, immediate) => { + let timeout + return (...args) => { + const later = () => { + timeout = null + if (!immediate) f(...args) + } + const callNow = immediate && !timeout + if (timeout) clearTimeout(timeout) + timeout = setTimeout(later, wait) + if (callNow) f(...args) + } +} + +const lerp = (min, max, x) => x * (max - min) + min +const easeOutQuad = x => 1 - (1 - x) * (1 - x) +const animate = (a, b, duration, ease, render) => new Promise(resolve => { + let start + const step = now => { + if (document.hidden) { + render(lerp(a, b, 1)) + return resolve() + } + start ??= now + const fraction = Math.min(1, (now - start) / duration) + render(lerp(a, b, ease(fraction))) + if (fraction < 1) requestAnimationFrame(step) + else resolve() + } + if (document.hidden) { + render(lerp(a, b, 1)) + return resolve() + } + requestAnimationFrame(step) +}) + +// collapsed range doesn't return client rects sometimes (or always?) +// try make get a non-collapsed range or element +const uncollapse = range => { + if (!range?.collapsed) return range + const { endOffset, endContainer } = range + if (endContainer.nodeType === 1) { + const node = endContainer.childNodes[endOffset] + if (node?.nodeType === 1) return node + return endContainer + } + if (endOffset + 1 < endContainer.length) range.setEnd(endContainer, endOffset + 1) + else if (endOffset > 1) range.setStart(endContainer, endOffset - 1) + else return endContainer.parentNode + return range +} + +const makeRange = (doc, node, start, end = start) => { + const range = doc.createRange() + range.setStart(node, start) + range.setEnd(node, end) + return range +} + +// use binary search to find an offset value in a text node +const bisectNode = (doc, node, cb, start = 0, end = node.nodeValue.length) => { + if (end - start === 1) { + const result = cb(makeRange(doc, node, start), makeRange(doc, node, end)) + return result < 0 ? start : end + } + const mid = Math.floor(start + (end - start) / 2) + const result = cb(makeRange(doc, node, start, mid), makeRange(doc, node, mid, end)) + return result < 0 ? bisectNode(doc, node, cb, start, mid) + : result > 0 ? bisectNode(doc, node, cb, mid, end) : mid +} + +const { SHOW_ELEMENT, SHOW_TEXT, SHOW_CDATA_SECTION, + FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter + +const filter = SHOW_ELEMENT | SHOW_TEXT | SHOW_CDATA_SECTION + +// needed cause there seems to be a bug in `getBoundingClientRect()` in Firefox +// where it fails to include rects that have zero width and non-zero height +// (CSSOM spec says "rectangles [...] of which the height or width is not zero") +// which makes the visible range include an extra space at column boundaries +const getBoundingClientRect = target => { + let top = Infinity, right = -Infinity, left = Infinity, bottom = -Infinity + for (const rect of target.getClientRects()) { + left = Math.min(left, rect.left) + top = Math.min(top, rect.top) + right = Math.max(right, rect.right) + bottom = Math.max(bottom, rect.bottom) + } + return new DOMRect(left, top, right - left, bottom - top) +} + +const getVisibleRange = (doc, start, end, mapRect) => { + // first get all visible nodes + const acceptNode = node => { + const name = node.localName?.toLowerCase() + // ignore all scripts, styles, and their children + if (name === 'script' || name === 'style') return FILTER_REJECT + if (node.nodeType === 1) { + const { left, right } = mapRect(node.getBoundingClientRect()) + // no need to check child nodes if it's completely out of view + if (right < start || left > end) return FILTER_REJECT + // elements must be completely in view to be considered visible + // because you can't specify offsets for elements + if (left >= start && right <= end) return FILTER_ACCEPT + // TODO: it should probably allow elements that do not contain text + // because they can exceed the whole viewport in both directions + // especially in scrolled mode + } else { + // ignore empty text nodes + if (!node.nodeValue?.trim()) return FILTER_SKIP + // create range to get rect + const range = doc.createRange() + range.selectNodeContents(node) + const { left, right } = mapRect(range.getBoundingClientRect()) + // it's visible if any part of it is in view + if (right >= start && left <= end) return FILTER_ACCEPT + } + return FILTER_SKIP + } + const walker = doc.createTreeWalker(doc.body, filter, { acceptNode }) + const nodes = [] + for (let node = walker.nextNode(); node; node = walker.nextNode()) + nodes.push(node) + + // we're only interested in the first and last visible nodes + const from = nodes[0] ?? doc.body + const to = nodes[nodes.length - 1] ?? from + + // find the offset at which visibility changes + const startOffset = from.nodeType === 1 ? 0 + : bisectNode(doc, from, (a, b) => { + const p = mapRect(getBoundingClientRect(a)) + const q = mapRect(getBoundingClientRect(b)) + if (p.right < start && q.left > start) return 0 + return q.left > start ? -1 : 1 + }) + const endOffset = to.nodeType === 1 ? 0 + : bisectNode(doc, to, (a, b) => { + const p = mapRect(getBoundingClientRect(a)) + const q = mapRect(getBoundingClientRect(b)) + if (p.right < end && q.left > end) return 0 + return q.left > end ? -1 : 1 + }) + + const range = doc.createRange() + range.setStart(from, startOffset) + range.setEnd(to, endOffset) + return range +} + +const selectionIsBackward = sel => { + const range = document.createRange() + range.setStart(sel.anchorNode, sel.anchorOffset) + range.setEnd(sel.focusNode, sel.focusOffset) + return range.collapsed +} + +const setSelectionTo = (target, collapse) => { + let range + if (target.startContainer) range = target.cloneRange() + else if (target.nodeType) { + range = document.createRange() + range.selectNode(target) + } + if (range) { + const sel = range.startContainer.ownerDocument.defaultView.getSelection() + if (sel) { + sel.removeAllRanges() + if (collapse === -1) range.collapse(true) + else if (collapse === 1) range.collapse() + sel.addRange(range) + } + } +} + +const getDirection = doc => { + const { defaultView } = doc + const { writingMode, direction } = defaultView.getComputedStyle(doc.body) + const vertical = writingMode === 'vertical-rl' + || writingMode === 'vertical-lr' + const rtl = doc.body.dir === 'rtl' + || direction === 'rtl' + || doc.documentElement.dir === 'rtl' + return { vertical, rtl } +} + +const getBackground = doc => { + const bodyStyle = doc.defaultView.getComputedStyle(doc.body) + return bodyStyle.backgroundColor === 'rgba(0, 0, 0, 0)' + && bodyStyle.backgroundImage === 'none' + ? doc.defaultView.getComputedStyle(doc.documentElement).background + : bodyStyle.background +} + +const makeMarginals = (length, part) => Array.from({ length }, () => { + const div = document.createElement('div') + const child = document.createElement('div') + div.append(child) + child.setAttribute('part', part) + return div +}) + +const setStylesImportant = (el, styles) => { + const { style } = el + for (const [k, v] of Object.entries(styles)) style.setProperty(k, v, 'important') +} + +class View { + #observer = new ResizeObserver(() => this.expand()) + #element = document.createElement('div') + #iframe = document.createElement('iframe') + #contentRange = document.createRange() + #overlayer + #vertical = false + #rtl = false + #column = true + #size + #layout = {} + constructor({ container, onExpand }) { + this.container = container + this.onExpand = onExpand + this.#iframe.setAttribute('part', 'filter') + this.#element.append(this.#iframe) + Object.assign(this.#element.style, { + boxSizing: 'content-box', + position: 'relative', + overflow: 'hidden', + flex: '0 0 auto', + width: '100%', height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }) + Object.assign(this.#iframe.style, { + overflow: 'hidden', + border: '0', + display: 'none', + width: '100%', height: '100%', + }) + // `allow-scripts` is needed for events because of WebKit bug + // https://bugs.webkit.org/show_bug.cgi?id=218086 + this.#iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') + this.#iframe.setAttribute('scrolling', 'no') + } + get element() { + return this.#element + } + get document() { + return this.#iframe.contentDocument + } + async load(src, afterLoad, beforeRender) { + if (typeof src !== 'string') throw new Error(`${src} is not string`) + return new Promise(resolve => { + this.#iframe.addEventListener('load', () => { + const doc = this.document + afterLoad?.(doc) + + // it needs to be visible for Firefox to get computed style + this.#iframe.style.display = 'block' + const { vertical, rtl } = getDirection(doc) + const background = getBackground(doc) + this.#iframe.style.display = 'none' + + this.#vertical = vertical + this.#rtl = rtl + + this.#contentRange.selectNodeContents(doc.body) + const layout = beforeRender?.({ vertical, rtl, background }) + this.#iframe.style.display = 'block' + this.render(layout) + this.#observer.observe(doc.body) + + // the resize observer above doesn't work in Firefox + // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1832939) + // until the bug is fixed we can at least account for font load + doc.fonts.ready.then(() => this.expand()) + + resolve() + }, { once: true }) + this.#iframe.src = src + }) + } + render(layout) { + if (!layout) return + this.#column = layout.flow !== 'scrolled' + this.#layout = layout + if (this.#column) this.columnize(layout) + else this.scrolled(layout) + } + scrolled({ gap, columnWidth }) { + const vertical = this.#vertical + const doc = this.document + setStylesImportant(doc.documentElement, { + 'box-sizing': 'border-box', + 'padding': vertical ? `${gap}px 0` : `0 ${gap}px`, + 'column-width': 'auto', + 'height': 'auto', + 'width': 'auto', + }) + setStylesImportant(doc.body, { + [vertical ? 'max-height' : 'max-width']: `${columnWidth}px`, + 'margin': 'auto', + }) + this.setImageSize() + this.expand() + } + columnize({ width, height, gap, columnWidth }) { + const vertical = this.#vertical + this.#size = vertical ? height : width + + const doc = this.document + setStylesImportant(doc.documentElement, { + 'box-sizing': 'border-box', + 'column-width': `${Math.trunc(columnWidth)}px`, + 'column-gap': `${gap}px`, + 'column-fill': 'auto', + ...(vertical + ? { 'width': `${width}px` } + : { 'height': `${height}px` }), + 'padding': vertical ? `${gap / 2}px 0` : `0 ${gap / 2}px`, + 'overflow': 'hidden', + // force wrap long words + 'overflow-wrap': 'break-word', + // reset some potentially problematic props + 'position': 'static', 'border': '0', 'margin': '0', + 'max-height': 'none', 'max-width': 'none', + 'min-height': 'none', 'min-width': 'none', + // fix glyph clipping in WebKit + '-webkit-line-box-contain': 'block glyphs replaced', + }) + setStylesImportant(doc.body, { + 'max-height': 'none', + 'max-width': 'none', + 'margin': '0', + }) + this.setImageSize() + this.expand() + } + setImageSize() { + const { width, height, margin } = this.#layout + const vertical = this.#vertical + const doc = this.document + for (const el of doc.body.querySelectorAll('img, svg, video')) { + // preserve max size if they are already set + const { maxHeight, maxWidth } = doc.defaultView.getComputedStyle(el) + setStylesImportant(el, { + 'max-height': vertical + ? (maxHeight !== 'none' && maxHeight !== '0px' ? maxHeight : '100%') + : `${height - margin * 2}px`, + 'max-width': vertical + ? `${width - margin * 2}px` + : (maxWidth !== 'none' && maxWidth !== '0px' ? maxWidth : '100%'), + 'object-fit': 'contain', + 'page-break-inside': 'avoid', + 'break-inside': 'avoid', + 'box-sizing': 'border-box', + }) + } + } + expand() { + const { documentElement } = this.document + if (this.#column) { + const side = this.#vertical ? 'height' : 'width' + const otherSide = this.#vertical ? 'width' : 'height' + const contentRect = this.#contentRange.getBoundingClientRect() + const rootRect = documentElement.getBoundingClientRect() + // offset caused by column break at the start of the page + // which seem to be supported only by WebKit and only for horizontal writing + const contentStart = this.#vertical ? 0 + : this.#rtl ? rootRect.right - contentRect.right : contentRect.left - rootRect.left + const contentSize = contentStart + contentRect[side] + const pageCount = Math.ceil(contentSize / this.#size) + const expandedSize = pageCount * this.#size + this.#element.style.padding = '0' + this.#iframe.style[side] = `${expandedSize}px` + this.#element.style[side] = `${expandedSize + this.#size * 2}px` + this.#iframe.style[otherSide] = '100%' + this.#element.style[otherSide] = '100%' + documentElement.style[side] = `${this.#size}px` + if (this.#overlayer) { + this.#overlayer.element.style.margin = '0' + this.#overlayer.element.style.left = this.#vertical ? '0' : `${this.#size}px` + this.#overlayer.element.style.top = this.#vertical ? `${this.#size}px` : '0' + this.#overlayer.element.style[side] = `${expandedSize}px` + this.#overlayer.redraw() + } + } else { + const side = this.#vertical ? 'width' : 'height' + const otherSide = this.#vertical ? 'height' : 'width' + const contentSize = documentElement.getBoundingClientRect()[side] + const expandedSize = contentSize + const { margin } = this.#layout + const padding = this.#vertical ? `0 ${margin}px` : `${margin}px 0` + this.#element.style.padding = padding + this.#iframe.style[side] = `${expandedSize}px` + this.#element.style[side] = `${expandedSize}px` + this.#iframe.style[otherSide] = '100%' + this.#element.style[otherSide] = '100%' + if (this.#overlayer) { + this.#overlayer.element.style.margin = padding + this.#overlayer.element.style.left = '0' + this.#overlayer.element.style.top = '0' + this.#overlayer.element.style[side] = `${expandedSize}px` + this.#overlayer.redraw() + } + } + this.onExpand() + } + set overlayer(overlayer) { + this.#overlayer = overlayer + this.#element.append(overlayer.element) + } + get overlayer() { + return this.#overlayer + } + destroy() { + if (this.document) this.#observer.unobserve(this.document.body) + } +} + +// NOTE: everything here assumes the so-called "negative scroll type" for RTL +export class Paginator extends HTMLElement { + static observedAttributes = [ + 'flow', 'gap', 'margin', + 'max-inline-size', 'max-block-size', 'max-column-count', + ] + #root = this.attachShadow({ mode: 'closed' }) + #observer = new ResizeObserver(() => this.render()) + #top + #background + #container + #header + #footer + #view + #vertical = false + #rtl = false + #margin = 0 + #index = -1 + #anchor = 0 // anchor view to a fraction (0-1), Range, or Element + #justAnchored = false + #locked = false // while true, prevent any further navigation + #styles + #styleMap = new WeakMap() + #mediaQuery = matchMedia('(prefers-color-scheme: dark)') + #mediaQueryListener + #scrollBounds + #touchState + #touchScrolled + #lastVisibleRange + constructor() { + super() + this.#root.innerHTML = ` +
+
+ +
+ +
+ ` + + this.#top = this.#root.getElementById('top') + this.#background = this.#root.getElementById('background') + this.#container = this.#root.getElementById('container') + this.#header = this.#root.getElementById('header') + this.#footer = this.#root.getElementById('footer') + + this.#observer.observe(this.#container) + this.#container.addEventListener('scroll', () => this.dispatchEvent(new Event('scroll'))) + this.#container.addEventListener('scroll', debounce(() => { + if (this.scrolled) { + if (this.#justAnchored) this.#justAnchored = false + else this.#afterScroll('scroll') + } + }, 250)) + + const opts = { passive: false } + this.addEventListener('touchstart', this.#onTouchStart.bind(this), opts) + this.addEventListener('touchmove', this.#onTouchMove.bind(this), opts) + this.addEventListener('touchend', this.#onTouchEnd.bind(this)) + this.addEventListener('load', ({ detail: { doc } }) => { + doc.addEventListener('touchstart', this.#onTouchStart.bind(this), opts) + doc.addEventListener('touchmove', this.#onTouchMove.bind(this), opts) + doc.addEventListener('touchend', this.#onTouchEnd.bind(this)) + }) + + this.addEventListener('relocate', ({ detail }) => { + if (detail.reason === 'selection') setSelectionTo(this.#anchor, 0) + else if (detail.reason === 'navigation') { + if (this.#anchor === 1) setSelectionTo(detail.range, 1) + else if (typeof this.#anchor === 'number') + setSelectionTo(detail.range, -1) + else setSelectionTo(this.#anchor, -1) + } + }) + const checkPointerSelection = debounce((range, sel) => { + if (!sel.rangeCount) return + const selRange = sel.getRangeAt(0) + const backward = selectionIsBackward(sel) + if (backward && selRange.compareBoundaryPoints(Range.START_TO_START, range) < 0) + this.prev() + else if (!backward && selRange.compareBoundaryPoints(Range.END_TO_END, range) > 0) + this.next() + }, 700) + this.addEventListener('load', ({ detail: { doc } }) => { + let isPointerSelecting = false + doc.addEventListener('pointerdown', () => isPointerSelecting = true) + doc.addEventListener('pointerup', () => isPointerSelecting = false) + let isKeyboardSelecting = false + doc.addEventListener('keydown', () => isKeyboardSelecting = true) + doc.addEventListener('keyup', () => isKeyboardSelecting = false) + doc.addEventListener('selectionchange', () => { + if (this.scrolled) return + const range = this.#lastVisibleRange + if (!range) return + const sel = doc.getSelection() + if (!sel.rangeCount) return + if (isPointerSelecting && sel.type === 'Range') + checkPointerSelection(range, sel) + else if (isKeyboardSelecting) { + const selRange = sel.getRangeAt(0).cloneRange() + const backward = selectionIsBackward(sel) + if (!backward) selRange.collapse() + this.#scrollToAnchor(selRange) + } + }) + doc.addEventListener('focusin', e => this.scrolled ? null : + // NOTE: `requestAnimationFrame` is needed in WebKit + requestAnimationFrame(() => this.#scrollToAnchor(e.target))) + }) + + this.#mediaQueryListener = () => { + if (!this.#view) return + this.#background.style.background = getBackground(this.#view.document) + } + this.#mediaQuery.addEventListener('change', this.#mediaQueryListener) + } + attributeChangedCallback(name, _, value) { + switch (name) { + case 'flow': + this.render() + break + case 'gap': + case 'margin': + case 'max-block-size': + case 'max-column-count': + this.#top.style.setProperty('--_' + name, value) + break + case 'max-inline-size': + // needs explicit `render()` as it doesn't necessarily resize + this.#top.style.setProperty('--_' + name, value) + this.render() + break + } + } + open(book) { + this.bookDir = book.dir + this.sections = book.sections + book.transformTarget?.addEventListener('data', ({ detail }) => { + if (detail.type !== 'text/css') return + const w = innerWidth + const h = innerHeight + detail.data = Promise.resolve(detail.data).then(data => data + // unprefix as most of the props are (only) supported unprefixed + .replace(/(?<=[{\s;])-epub-/gi, '') + // replace vw and vh as they cause problems with layout + .replace(/(\d*\.?\d+)vw/gi, (_, d) => parseFloat(d) * w / 100 + 'px') + .replace(/(\d*\.?\d+)vh/gi, (_, d) => parseFloat(d) * h / 100 + 'px') + // `page-break-*` unsupported in columns; replace with `column-break-*` + .replace(/page-break-(after|before|inside)\s*:/gi, (_, x) => + `-webkit-column-break-${x}:`) + .replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi, (_, x, y) => + `break-${x}: ${y ?? ''}column`)) + }) + } + #createView() { + if (this.#view) { + this.#view.destroy() + this.#container.removeChild(this.#view.element) + } + this.#view = new View({ + container: this, + onExpand: () => this.#scrollToAnchor(this.#anchor), + }) + this.#container.append(this.#view.element) + return this.#view + } + #beforeRender({ vertical, rtl, background }) { + this.#vertical = vertical + this.#rtl = rtl + this.#top.classList.toggle('vertical', vertical) + + // set background to `doc` background + // this is needed because the iframe does not fill the whole element + this.#background.style.background = background + + const { width, height } = this.#container.getBoundingClientRect() + const size = vertical ? height : width + + const style = getComputedStyle(this.#top) + const maxInlineSize = parseFloat(style.getPropertyValue('--_max-inline-size')) + const maxColumnCount = parseInt(style.getPropertyValue('--_max-column-count-spread')) + const margin = parseFloat(style.getPropertyValue('--_margin')) + this.#margin = margin + + const g = parseFloat(style.getPropertyValue('--_gap')) / 100 + // The gap will be a percentage of the #container, not the whole view. + // This means the outer padding will be bigger than the column gap. Let + // `a` be the gap percentage. The actual percentage for the column gap + // will be (1 - a) * a. Let us call this `b`. + // + // To make them the same, we start by shrinking the outer padding + // setting to `b`, but keep the column gap setting the same at `a`. Then + // the actual size for the column gap will be (1 - b) * a. Repeating the + // process again and again, we get the sequence + // x₁ = (1 - b) * a + // x₂ = (1 - x₁) * a + // ... + // which converges to x = (1 - x) * a. Solving for x, x = a / (1 + a). + // So to make the spacing even, we must shrink the outer padding with + // f(x) = x / (1 + x). + // But we want to keep the outer padding, and make the inner gap bigger. + // So we apply the inverse, f⁻¹ = -x / (x - 1) to the column gap. + const gap = -g / (g - 1) * size + + const flow = this.getAttribute('flow') + if (flow === 'scrolled') { + // FIXME: vertical-rl only, not -lr + this.setAttribute('dir', vertical ? 'rtl' : 'ltr') + this.#top.style.padding = '0' + const columnWidth = maxInlineSize + + this.heads = null + this.feet = null + this.#header.replaceChildren() + this.#footer.replaceChildren() + + return { flow, margin, gap, columnWidth } + } + + const divisor = Math.min(maxColumnCount, Math.ceil(size / maxInlineSize)) + const columnWidth = (size / divisor) - gap + this.setAttribute('dir', rtl ? 'rtl' : 'ltr') + + const marginalDivisor = vertical + ? Math.min(2, Math.ceil(width / maxInlineSize)) + : divisor + const marginalStyle = { + gridTemplateColumns: `repeat(${marginalDivisor}, 1fr)`, + gap: `${gap}px`, + direction: this.bookDir === 'rtl' ? 'rtl' : 'ltr', + } + Object.assign(this.#header.style, marginalStyle) + Object.assign(this.#footer.style, marginalStyle) + const heads = makeMarginals(marginalDivisor, 'head') + const feet = makeMarginals(marginalDivisor, 'foot') + this.heads = heads.map(el => el.children[0]) + this.feet = feet.map(el => el.children[0]) + this.#header.replaceChildren(...heads) + this.#footer.replaceChildren(...feet) + + return { height, width, margin, gap, columnWidth } + } + render() { + if (!this.#view) return + this.#view.render(this.#beforeRender({ + vertical: this.#vertical, + rtl: this.#rtl, + })) + this.#scrollToAnchor(this.#anchor) + } + get scrolled() { + return this.getAttribute('flow') === 'scrolled' + } + get scrollProp() { + const { scrolled } = this + return this.#vertical ? (scrolled ? 'scrollLeft' : 'scrollTop') + : scrolled ? 'scrollTop' : 'scrollLeft' + } + get sideProp() { + const { scrolled } = this + return this.#vertical ? (scrolled ? 'width' : 'height') + : scrolled ? 'height' : 'width' + } + get size() { + return this.#container.getBoundingClientRect()[this.sideProp] + } + get viewSize() { + return this.#view.element.getBoundingClientRect()[this.sideProp] + } + get start() { + return Math.abs(this.#container[this.scrollProp]) + } + get end() { + return this.start + this.size + } + get page() { + return Math.floor(((this.start + this.end) / 2) / this.size) + } + get pages() { + return Math.round(this.viewSize / this.size) + } + scrollBy(dx, dy) { + const delta = this.#vertical ? dy : dx + const element = this.#container + const { scrollProp } = this + const [offset, a, b] = this.#scrollBounds + const rtl = this.#rtl + const min = rtl ? offset - b : offset - a + const max = rtl ? offset + a : offset + b + element[scrollProp] = Math.max(min, Math.min(max, + element[scrollProp] + delta)) + } + snap(vx, vy) { + const velocity = this.#vertical ? vy : vx + const [offset, a, b] = this.#scrollBounds + const { start, end, pages, size } = this + const min = Math.abs(offset) - a + const max = Math.abs(offset) + b + const d = velocity * (this.#rtl ? -size : size) + const page = Math.floor( + Math.max(min, Math.min(max, (start + end) / 2 + + (isNaN(d) ? 0 : d))) / size) + + this.#scrollToPage(page, 'snap').then(() => { + const dir = page <= 0 ? -1 : page >= pages - 1 ? 1 : null + if (dir) return this.#goTo({ + index: this.#adjacentIndex(dir), + anchor: dir < 0 ? () => 1 : () => 0, + }) + }) + } + #onTouchStart(e) { + const touch = e.changedTouches[0] + this.#touchState = { + x: touch?.screenX, y: touch?.screenY, + t: e.timeStamp, + vx: 0, xy: 0, + } + } + #onTouchMove(e) { + const state = this.#touchState + if (state.pinched) return + state.pinched = globalThis.visualViewport.scale > 1 + if (this.scrolled || state.pinched) return + if (e.touches.length > 1) { + if (this.#touchScrolled) e.preventDefault() + return + } + e.preventDefault() + const touch = e.changedTouches[0] + const x = touch.screenX, y = touch.screenY + const dx = state.x - x, dy = state.y - y + const dt = e.timeStamp - state.t + state.x = x + state.y = y + state.t = e.timeStamp + state.vx = dx / dt + state.vy = dy / dt + this.#touchScrolled = true + this.scrollBy(dx, dy) + } + #onTouchEnd() { + this.#touchScrolled = false + if (this.scrolled) return + + // XXX: Firefox seems to report scale as 1... sometimes...? + // at this point I'm basically throwing `requestAnimationFrame` at + // anything that doesn't work + requestAnimationFrame(() => { + if (globalThis.visualViewport.scale === 1) + this.snap(this.#touchState.vx, this.#touchState.vy) + }) + } + // allows one to process rects as if they were LTR and horizontal + #getRectMapper() { + if (this.scrolled) { + const size = this.viewSize + const margin = this.#margin + return this.#vertical + ? ({ left, right }) => + ({ left: size - right - margin, right: size - left - margin }) + : ({ top, bottom }) => ({ left: top + margin, right: bottom + margin }) + } + const pxSize = this.pages * this.size + return this.#rtl + ? ({ left, right }) => + ({ left: pxSize - right, right: pxSize - left }) + : this.#vertical + ? ({ top, bottom }) => ({ left: top, right: bottom }) + : f => f + } + async #scrollToRect(rect, reason) { + if (this.scrolled) { + const offset = this.#getRectMapper()(rect).left - this.#margin + return this.#scrollTo(offset, reason) + } + const offset = this.#getRectMapper()(rect).left + return this.#scrollToPage(Math.floor(offset / this.size) + (this.#rtl ? -1 : 1), reason) + } + async #scrollTo(offset, reason, smooth) { + const element = this.#container + const { scrollProp, size } = this + if (element[scrollProp] === offset) { + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + return + } + // FIXME: vertical-rl only, not -lr + if (this.scrolled && this.#vertical) offset = -offset + if ((reason === 'snap' || smooth) && this.hasAttribute('animated')) return animate( + element[scrollProp], offset, 300, easeOutQuad, + x => element[scrollProp] = x, + ).then(() => { + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + }) + else { + element[scrollProp] = offset + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + } + } + async #scrollToPage(page, reason, smooth) { + const offset = this.size * (this.#rtl ? -page : page) + return this.#scrollTo(offset, reason, smooth) + } + async scrollToAnchor(anchor, select) { + return this.#scrollToAnchor(anchor, select ? 'selection' : 'navigation') + } + async #scrollToAnchor(anchor, reason = 'anchor') { + this.#anchor = anchor + const rects = uncollapse(anchor)?.getClientRects?.() + // if anchor is an element or a range + if (rects) { + // when the start of the range is immediately after a hyphen in the + // previous column, there is an extra zero width rect in that column + const rect = Array.from(rects) + .find(r => r.width > 0 && r.height > 0) || rects[0] + if (!rect) return + await this.#scrollToRect(rect, reason) + return + } + // if anchor is a fraction + if (this.scrolled) { + await this.#scrollTo(anchor * this.viewSize, reason) + return + } + const { pages } = this + if (!pages) return + const textPages = pages - 2 + const newPage = Math.round(anchor * (textPages - 1)) + await this.#scrollToPage(newPage + 1, reason) + } + #getVisibleRange() { + if (this.scrolled) return getVisibleRange(this.#view.document, + this.start + this.#margin, this.end - this.#margin, this.#getRectMapper()) + const size = this.#rtl ? -this.size : this.size + return getVisibleRange(this.#view.document, + this.start - size, this.end - size, this.#getRectMapper()) + } + #afterScroll(reason) { + const range = this.#getVisibleRange() + this.#lastVisibleRange = range + // don't set new anchor if relocation was to scroll to anchor + if (reason !== 'selection' && reason !== 'navigation' && reason !== 'anchor') + this.#anchor = range + else this.#justAnchored = true + + const index = this.#index + const detail = { reason, range, index } + if (this.scrolled) detail.fraction = this.start / this.viewSize + else if (this.pages > 0) { + const { page, pages } = this + this.#header.style.visibility = page > 1 ? 'visible' : 'hidden' + detail.fraction = (page - 1) / (pages - 2) + detail.size = 1 / (pages - 2) + } + this.dispatchEvent(new CustomEvent('relocate', { detail })) + } + async #display(promise) { + const { index, src, anchor, onLoad, select } = await promise + this.#index = index + const hasFocus = this.#view?.document?.hasFocus() + if (src) { + const view = this.#createView() + const afterLoad = doc => { + if (doc.head) { + const $styleBefore = doc.createElement('style') + doc.head.prepend($styleBefore) + const $style = doc.createElement('style') + doc.head.append($style) + this.#styleMap.set(doc, [$styleBefore, $style]) + } + onLoad?.({ doc, index }) + } + const beforeRender = this.#beforeRender.bind(this) + await view.load(src, afterLoad, beforeRender) + this.dispatchEvent(new CustomEvent('create-overlayer', { + detail: { + doc: view.document, index, + attach: overlayer => view.overlayer = overlayer, + }, + })) + this.#view = view + } + await this.scrollToAnchor((typeof anchor === 'function' + ? anchor(this.#view.document) : anchor) ?? 0, select) + if (hasFocus) this.focusView() + } + #canGoToIndex(index) { + return index >= 0 && index <= this.sections.length - 1 + } + async #goTo({ index, anchor, select}) { + if (index === this.#index) await this.#display({ index, anchor, select }) + else { + const oldIndex = this.#index + const onLoad = detail => { + this.sections[oldIndex]?.unload?.() + this.setStyles(this.#styles) + this.dispatchEvent(new CustomEvent('load', { detail })) + } + await this.#display(Promise.resolve(this.sections[index].load()) + .then(src => ({ index, src, anchor, onLoad, select })) + .catch(e => { + console.warn(e) + console.warn(new Error(`Failed to load section ${index}`)) + return {} + })) + } + } + async goTo(target) { + if (this.#locked) return + const resolved = await target + if (this.#canGoToIndex(resolved.index)) return this.#goTo(resolved) + } + #scrollPrev(distance) { + if (!this.#view) return true + if (this.scrolled) { + if (this.start > 0) return this.#scrollTo( + Math.max(0, this.start - (distance ?? this.size)), null, true) + return true + } + if (this.atStart) return + const page = this.page - 1 + return this.#scrollToPage(page, 'page', true).then(() => page <= 0) + } + #scrollNext(distance) { + if (!this.#view) return true + if (this.scrolled) { + if (this.viewSize - this.end > 2) return this.#scrollTo( + Math.min(this.viewSize, distance ? this.start + distance : this.end), null, true) + return true + } + if (this.atEnd) return + const page = this.page + 1 + const pages = this.pages + return this.#scrollToPage(page, 'page', true).then(() => page >= pages - 1) + } + get atStart() { + return this.#adjacentIndex(-1) == null && this.page <= 1 + } + get atEnd() { + return this.#adjacentIndex(1) == null && this.page >= this.pages - 2 + } + #adjacentIndex(dir) { + for (let index = this.#index + dir; this.#canGoToIndex(index); index += dir) + if (this.sections[index]?.linear !== 'no') return index + } + async #turnPage(dir, distance) { + if (this.#locked) return + this.#locked = true + const prev = dir === -1 + const shouldGo = await (prev ? this.#scrollPrev(distance) : this.#scrollNext(distance)) + if (shouldGo) await this.#goTo({ + index: this.#adjacentIndex(dir), + anchor: prev ? () => 1 : () => 0, + }) + if (shouldGo || !this.hasAttribute('animated')) await wait(100) + this.#locked = false + } + prev(distance) { + return this.#turnPage(-1, distance) + } + next(distance) { + return this.#turnPage(1, distance) + } + prevSection() { + return this.goTo({ index: this.#adjacentIndex(-1) }) + } + nextSection() { + return this.goTo({ index: this.#adjacentIndex(1) }) + } + firstSection() { + const index = this.sections.findIndex(section => section.linear !== 'no') + return this.goTo({ index }) + } + lastSection() { + const index = this.sections.findLastIndex(section => section.linear !== 'no') + return this.goTo({ index }) + } + getContents() { + if (this.#view) return [{ + index: this.#index, + overlayer: this.#view.overlayer, + doc: this.#view.document, + }] + return [] + } + setStyles(styles) { + this.#styles = styles + const $$styles = this.#styleMap.get(this.#view?.document) + if (!$$styles) return + const [$beforeStyle, $style] = $$styles + if (Array.isArray(styles)) { + const [beforeStyle, style] = styles + $beforeStyle.textContent = beforeStyle + $style.textContent = style + } else $style.textContent = styles + + // NOTE: needs `requestAnimationFrame` in Chromium + requestAnimationFrame(() => + this.#background.style.background = getBackground(this.#view.document)) + + // needed because the resize observer doesn't work in Firefox + this.#view?.document?.fonts?.ready?.then(() => this.#view.expand()) + } + focusView() { + this.#view.document.defaultView.focus() + } + destroy() { + this.#observer.unobserve(this) + this.#view.destroy() + this.#view = null + this.sections[this.#index]?.unload?.() + this.#mediaQuery.removeEventListener('change', this.#mediaQueryListener) + } +} + +customElements.define('foliate-paginator', Paginator) diff --git a/static/vendor/foliate-js/pdf.js b/static/vendor/foliate-js/pdf.js new file mode 100644 index 0000000..c998bf4 --- /dev/null +++ b/static/vendor/foliate-js/pdf.js @@ -0,0 +1,180 @@ +const pdfjsPath = path => new URL(`vendor/pdfjs/${path}`, import.meta.url).toString() + +import './vendor/pdfjs/pdf.mjs' +const pdfjsLib = globalThis.pdfjsLib +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsPath('pdf.worker.mjs') + +const fetchText = async url => await (await fetch(url)).text() + +// https://raw.githubusercontent.com/mozilla/pdf.js/refs/tags/v5.5.207/web/text_layer_builder.css +const textLayerBuilderCSS = await fetchText(pdfjsPath('text_layer_builder.css')) + +// https://raw.githubusercontent.com/mozilla/pdf.js/refs/tags/v5.5.207/web/annotation_layer_builder.css +const annotationLayerBuilderCSS = await fetchText(pdfjsPath('annotation_layer_builder.css')) + +const render = async (page, doc, zoom) => { + const scale = zoom * devicePixelRatio + doc.documentElement.style.transform = `scale(${1 / devicePixelRatio})` + doc.documentElement.style.transformOrigin = 'top left' + doc.documentElement.style.setProperty('--scale-factor', scale) + const viewport = page.getViewport({ scale }) + + // the canvas must be in the `PDFDocument`'s `ownerDocument` + // (`globalThis.document` by default); that's where the fonts are loaded + const canvas = document.createElement('canvas') + canvas.height = viewport.height + canvas.width = viewport.width + const canvasContext = canvas.getContext('2d') + await page.render({ canvasContext, viewport }).promise + doc.querySelector('#canvas').replaceChildren(doc.adoptNode(canvas)) + + const container = doc.querySelector('.textLayer') + const textLayer = new pdfjsLib.TextLayer({ + textContentSource: await page.streamTextContent(), + container, viewport, + }) + await textLayer.render() + + // hide "offscreen" canvases appended to docuemnt when rendering text layer + // https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/pdf_viewer.css#L51-L58 + for (const canvas of document.querySelectorAll('.hiddenCanvasElement')) + Object.assign(canvas.style, { + position: 'absolute', + top: '0', + left: '0', + width: '0', + height: '0', + display: 'none', + }) + + // fix text selection + // https://github.com/mozilla/pdf.js/blob/642b9a5ae67ef642b9a8808fd9efd447e8c350e2/web/text_layer_builder.js#L105-L107 + const endOfContent = document.createElement('div') + endOfContent.className = 'endOfContent' + container.append(endOfContent) + // TODO: this only works in Firefox; see https://github.com/mozilla/pdf.js/pull/17923 + container.onpointerdown = () => container.classList.add('selecting') + container.onpointerup = () => container.classList.remove('selecting') + + const div = doc.querySelector('.annotationLayer') + const linkService = { + goToDestination: () => {}, + getDestinationHash: dest => JSON.stringify(dest), + addLinkAttributes: (link, url) => link.href = url, + } + await new pdfjsLib.AnnotationLayer({ page, viewport, div, linkService }) + .render({ annotations: await page.getAnnotations() }) +} + +const renderPage = async (page, getImageBlob) => { + const viewport = page.getViewport({ scale: 1 }) + if (getImageBlob) { + const canvas = document.createElement('canvas') + canvas.height = viewport.height + canvas.width = viewport.width + const canvasContext = canvas.getContext('2d') + await page.render({ canvasContext, viewport }).promise + return new Promise(resolve => canvas.toBlob(resolve)) + } + const src = URL.createObjectURL(new Blob([` + + + + + +
+
+
+ `], { type: 'text/html' })) + const onZoom = ({ doc, scale }) => render(page, doc, scale) + return { src, onZoom } +} + +const makeTOCItem = item => ({ + label: item.title, + href: JSON.stringify(item.dest), + subitems: item.items.length ? item.items.map(makeTOCItem) : null, +}) + +export const makePDF = async file => { + const transport = new pdfjsLib.PDFDataRangeTransport(file.size, []) + transport.requestDataRange = (begin, end) => { + file.slice(begin, end).arrayBuffer().then(chunk => { + transport.onDataRange(begin, chunk) + }) + } + const pdf = await pdfjsLib.getDocument({ + range: transport, + cMapUrl: pdfjsPath('cmaps/'), + standardFontDataUrl: pdfjsPath('standard_fonts/'), + isEvalSupported: false, + }).promise + + const book = { rendition: { layout: 'pre-paginated' } } + + const { metadata, info } = await pdf.getMetadata() ?? {} + // TODO: for better results, parse `metadata.getRaw()` + book.metadata = { + title: metadata?.get('dc:title') ?? info?.Title, + author: metadata?.get('dc:creator') ?? info?.Author, + contributor: metadata?.get('dc:contributor'), + description: metadata?.get('dc:description') ?? info?.Subject, + language: metadata?.get('dc:language'), + publisher: metadata?.get('dc:publisher'), + subject: metadata?.get('dc:subject'), + identifier: metadata?.get('dc:identifier'), + source: metadata?.get('dc:source'), + rights: metadata?.get('dc:rights'), + } + + const outline = await pdf.getOutline() + book.toc = outline?.map(makeTOCItem) + + const cache = new Map() + book.sections = Array.from({ length: pdf.numPages }).map((_, i) => ({ + id: i, + load: async () => { + const cached = cache.get(i) + if (cached) return cached + const url = await renderPage(await pdf.getPage(i + 1)) + cache.set(i, url) + return url + }, + size: 1000, + })) + book.isExternal = uri => /^\w+:/i.test(uri) + book.resolveHref = async href => { + const parsed = JSON.parse(href) + const dest = typeof parsed === 'string' + ? await pdf.getDestination(parsed) : parsed + const index = await pdf.getPageIndex(dest[0]) + return { index } + } + book.splitTOCHref = async href => { + const parsed = JSON.parse(href) + const dest = typeof parsed === 'string' + ? await pdf.getDestination(parsed) : parsed + const index = await pdf.getPageIndex(dest[0]) + return [index, null] + } + book.getTOCFragment = doc => doc.documentElement + book.getCover = async () => renderPage(await pdf.getPage(1), true) + book.destroy = () => pdf.destroy() + return book +} diff --git a/static/vendor/foliate-js/progress.js b/static/vendor/foliate-js/progress.js new file mode 100644 index 0000000..f01c512 --- /dev/null +++ b/static/vendor/foliate-js/progress.js @@ -0,0 +1,113 @@ +// assign a unique ID for each TOC item +const assignIDs = toc => { + let id = 0 + const assignID = item => { + item.id = id++ + if (item.subitems) for (const subitem of item.subitems) assignID(subitem) + } + for (const item of toc) assignID(item) + return toc +} + +const flatten = items => items + .map(item => item.subitems?.length + ? [item, flatten(item.subitems)].flat() + : item) + .flat() + +export class TOCProgress { + async init({ toc, ids, splitHref, getFragment }) { + assignIDs(toc) + const items = flatten(toc) + const grouped = new Map() + for (const [i, item] of items.entries()) { + const [id, fragment] = await splitHref(item?.href) ?? [] + const value = { fragment, item } + if (grouped.has(id)) grouped.get(id).items.push(value) + else grouped.set(id, { prev: items[i - 1], items: [value] }) + } + const map = new Map() + for (const [i, id] of ids.entries()) { + if (grouped.has(id)) map.set(id, grouped.get(id)) + else map.set(id, map.get(ids[i - 1])) + } + this.ids = ids + this.map = map + this.getFragment = getFragment + } + getProgress(index, range) { + if (!this.ids) return + const id = this.ids[index] + const obj = this.map.get(id) + if (!obj) return null + const { prev, items } = obj + if (!items) return prev + if (!range || items.length === 1 && !items[0].fragment) return items[0].item + + const doc = range.startContainer.getRootNode() + for (const [i, { fragment }] of items.entries()) { + const el = this.getFragment(doc, fragment) + if (!el) continue + if (range.comparePoint(el, 0) > 0) + return (items[i - 1]?.item ?? prev) + } + return items[items.length - 1].item + } +} + +export class SectionProgress { + constructor(sections, sizePerLoc, sizePerTimeUnit) { + this.sizes = sections.map(s => s.linear != 'no' && s.size > 0 ? s.size : 0) + this.sizePerLoc = sizePerLoc + this.sizePerTimeUnit = sizePerTimeUnit + this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0) + this.sectionFractions = this.#getSectionFractions() + } + #getSectionFractions() { + const { sizeTotal } = this + const results = [0] + let sum = 0 + for (const size of this.sizes) results.push((sum += size) / sizeTotal) + return results + } + // get progress given index of and fractions within a section + getProgress(index, fractionInSection, pageFraction = 0) { + const { sizes, sizePerLoc, sizePerTimeUnit, sizeTotal } = this + const sizeInSection = sizes[index] ?? 0 + const sizeBefore = sizes.slice(0, index).reduce((a, b) => a + b, 0) + const size = sizeBefore + fractionInSection * sizeInSection + const nextSize = size + pageFraction * sizeInSection + const remainingTotal = sizeTotal - size + const remainingSection = (1 - fractionInSection) * sizeInSection + return { + fraction: nextSize / sizeTotal, + section: { + current: index, + total: sizes.length, + }, + location: { + current: Math.floor(size / sizePerLoc), + next: Math.floor(nextSize / sizePerLoc), + total: Math.ceil(sizeTotal / sizePerLoc), + }, + time: { + section: remainingSection / sizePerTimeUnit, + total: remainingTotal / sizePerTimeUnit, + }, + } + } + // the inverse of `getProgress` + // get index of and fraction in section based on total fraction + getSection(fraction) { + if (fraction <= 0) return [0, 0] + if (fraction >= 1) return [this.sizes.length - 1, 1] + fraction = fraction + Number.EPSILON + const { sizeTotal } = this + let index = this.sectionFractions.findIndex(x => x > fraction) - 1 + if (index < 0) return [0, 0] + while (!this.sizes[index]) index++ + const fractionInSection = (fraction - this.sectionFractions[index]) + / (this.sizes[index] / sizeTotal) + return [index, fractionInSection] + } +} diff --git a/static/vendor/foliate-js/quote-image.js b/static/vendor/foliate-js/quote-image.js new file mode 100644 index 0000000..1336c59 --- /dev/null +++ b/static/vendor/foliate-js/quote-image.js @@ -0,0 +1,86 @@ +const SVG_NS = 'http://www.w3.org/2000/svg' + +// bisect +const fit = (el, a = 1, b = 50) => { + const c = Math.floor(a + (b - a) / 2) + el.style.fontSize = `${c}px` + if (b - a === 1) return + if (el.scrollHeight > el.clientHeight + || el.scrollWidth > el.clientWidth) fit(el, a, c) + else fit(el, c, b) +} + +const width = 540 +const height = 540 +const pixelRatio = 2 + +const html = ` +
+ +
“
+
+
+
+
+ +
+
+ +
+
+
+
 
+
` + +// TODO: lang, vertical writing +customElements.define('foliate-quoteimage', class extends HTMLElement { + #root = this.attachShadow({ mode: 'closed' }) + constructor() { + super() + this.#root.innerHTML = html + } + async getBlob({ title, author, text }) { + this.#root.querySelector('#title').textContent = title + this.#root.querySelector('#author').textContent = author + this.#root.querySelector('#text').innerText = text + + fit(this.#root.querySelector('main')) + + const img = document.createElement('img') + return new Promise(resolve => { + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = pixelRatio * width + canvas.height = pixelRatio * height + const ctx = canvas.getContext('2d') + ctx.drawImage(img, 0, 0, canvas.width, canvas.height) + canvas.toBlob(resolve) + } + const doc = document.implementation.createDocument(SVG_NS, 'svg') + doc.documentElement.setAttribute('viewBox', `0 0 ${width} ${height}`) + const obj = doc.createElementNS(SVG_NS, 'foreignObject') + obj.setAttribute('width', width) + obj.setAttribute('height', height) + obj.append(doc.importNode(this.#root.querySelector('main'), true)) + doc.documentElement.append(obj) + img.src = 'data:image/svg+xml;charset=utf-8,' + + new XMLSerializer().serializeToString(doc) + }) + } +}) diff --git a/static/vendor/foliate-js/search.js b/static/vendor/foliate-js/search.js new file mode 100644 index 0000000..a6f176f --- /dev/null +++ b/static/vendor/foliate-js/search.js @@ -0,0 +1,130 @@ +// length for context in excerpts +const CONTEXT_LENGTH = 50 + +const normalizeWhitespace = str => str.replace(/\s+/g, ' ') + +const makeExcerpt = (strs, { startIndex, startOffset, endIndex, endOffset }) => { + const start = strs[startIndex] + const end = strs[endIndex] + const match = start === end + ? start.slice(startOffset, endOffset) + : start.slice(startOffset) + + strs.slice(start + 1, end).join('') + + end.slice(0, endOffset) + const trimmedStart = normalizeWhitespace(start.slice(0, startOffset)).trimStart() + const trimmedEnd = normalizeWhitespace(end.slice(endOffset)).trimEnd() + const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…' + const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…' + const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}` + const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}` + return { pre, match, post } +} + +const simpleSearch = function* (strs, query, options = {}) { + const { locales = 'en', sensitivity } = options + const matchCase = sensitivity === 'variant' + const haystack = strs.join('') + const lowerHaystack = matchCase ? haystack : haystack.toLocaleLowerCase(locales) + const needle = matchCase ? query : query.toLocaleLowerCase(locales) + const needleLength = needle.length + let index = -1 + let strIndex = -1 + let sum = 0 + do { + index = lowerHaystack.indexOf(needle, index + 1) + if (index > -1) { + while (sum <= index) sum += strs[++strIndex].length + const startIndex = strIndex + const startOffset = index - (sum - strs[strIndex].length) + const end = index + needleLength + while (sum <= end) sum += strs[++strIndex].length + const endIndex = strIndex + const endOffset = end - (sum - strs[strIndex].length) + const range = { startIndex, startOffset, endIndex, endOffset } + yield { range, excerpt: makeExcerpt(strs, range) } + } + } while (index > -1) +} + +const segmenterSearch = function* (strs, query, options = {}) { + const { locales = 'en', granularity = 'word', sensitivity = 'base' } = options + let segmenter, collator + try { + segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity }) + collator = new Intl.Collator(locales, { sensitivity }) + } catch (e) { + console.warn(e) + segmenter = new Intl.Segmenter('en', { usage: 'search', granularity }) + collator = new Intl.Collator('en', { sensitivity }) + } + const queryLength = Array.from(segmenter.segment(query)).length + + const substrArr = [] + let strIndex = 0 + let segments = segmenter.segment(strs[strIndex])[Symbol.iterator]() + main: while (strIndex < strs.length) { + while (substrArr.length < queryLength) { + const { done, value } = segments.next() + if (done) { + // the current string is exhausted + // move on to the next string + strIndex++ + if (strIndex < strs.length) { + segments = segmenter.segment(strs[strIndex])[Symbol.iterator]() + continue + } else break main + } + const { index, segment } = value + // ignore formatting characters + if (!/[^\p{Format}]/u.test(segment)) continue + // normalize whitespace + if (/\s/u.test(segment)) { + if (!/\s/u.test(substrArr[substrArr.length - 1]?.segment)) + substrArr.push({ strIndex, index, segment: ' ' }) + continue + } + value.strIndex = strIndex + substrArr.push(value) + } + const substr = substrArr.map(x => x.segment).join('') + if (collator.compare(query, substr) === 0) { + const endIndex = strIndex + const lastSeg = substrArr[substrArr.length - 1] + const endOffset = lastSeg.index + lastSeg.segment.length + const startIndex = substrArr[0].strIndex + const startOffset = substrArr[0].index + const range = { startIndex, startOffset, endIndex, endOffset } + yield { range, excerpt: makeExcerpt(strs, range) } + } + substrArr.shift() + } +} + +export const search = (strs, query, options) => { + const { granularity = 'grapheme', sensitivity = 'base' } = options + if (!Intl?.Segmenter || granularity === 'grapheme' + && (sensitivity === 'variant' || sensitivity === 'accent')) + return simpleSearch(strs, query, options) + return segmenterSearch(strs, query, options) +} + +export const searchMatcher = (textWalker, opts) => { + const { defaultLocale, matchCase, matchDiacritics, matchWholeWords, acceptNode } = opts + return function* (doc, query) { + const iter = textWalker(doc, function* (strs, makeRange) { + for (const result of search(strs, query, { + locales: doc.body.lang || doc.documentElement.lang || defaultLocale || 'en', + granularity: matchWholeWords ? 'word' : 'grapheme', + sensitivity: matchDiacritics && matchCase ? 'variant' + : matchDiacritics && !matchCase ? 'accent' + : !matchDiacritics && matchCase ? 'case' + : 'base', + })) { + const { startIndex, startOffset, endIndex, endOffset } = result.range + result.range = makeRange(startIndex, startOffset, endIndex, endOffset) + yield result + } + }, acceptNode) + for (const result of iter) yield result + } +} diff --git a/static/vendor/foliate-js/text-walker.js b/static/vendor/foliate-js/text-walker.js new file mode 100644 index 0000000..c1249b7 --- /dev/null +++ b/static/vendor/foliate-js/text-walker.js @@ -0,0 +1,43 @@ +const walkRange = (range, walker) => { + const nodes = [] + for (let node = walker.currentNode; node; node = walker.nextNode()) { + const compare = range.comparePoint(node, 0) + if (compare === 0) nodes.push(node) + else if (compare > 0) break + } + return nodes +} + +const walkDocument = (_, walker) => { + const nodes = [] + for (let node = walker.nextNode(); node; node = walker.nextNode()) + nodes.push(node) + return nodes +} + +const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT + | NodeFilter.SHOW_CDATA_SECTION + +const acceptNode = node => { + if (node.nodeType === 1) { + const name = node.tagName.toLowerCase() + if (name === 'script' || name === 'style') return NodeFilter.FILTER_REJECT + return NodeFilter.FILTER_SKIP + } + return NodeFilter.FILTER_ACCEPT +} + +export const textWalker = function* (x, func, filterFunc) { + const root = x.commonAncestorContainer ?? x.body ?? x + const walker = document.createTreeWalker(root, filter, { acceptNode: filterFunc || acceptNode }) + const walk = x.commonAncestorContainer ? walkRange : walkDocument + const nodes = walk(x, walker) + const strs = nodes.map(node => node.nodeValue ?? '') + const makeRange = (startIndex, startOffset, endIndex, endOffset) => { + const range = document.createRange() + range.setStart(nodes[startIndex], startOffset) + range.setEnd(nodes[endIndex], endOffset) + return range + } + for (const match of func(strs, makeRange)) yield match +} diff --git a/static/vendor/foliate-js/tts.js b/static/vendor/foliate-js/tts.js new file mode 100644 index 0000000..bf665c5 --- /dev/null +++ b/static/vendor/foliate-js/tts.js @@ -0,0 +1,278 @@ +const NS = { + XML: 'http://www.w3.org/XML/1998/namespace', + SSML: 'http://www.w3.org/2001/10/synthesis', +} + +const blockTags = new Set([ + 'article', 'aside', 'audio', 'blockquote', 'caption', + 'details', 'dialog', 'div', 'dl', 'dt', 'dd', + 'figure', 'footer', 'form', 'figcaption', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', + 'main', 'math', 'nav', 'ol', 'p', 'pre', 'section', 'tr', +]) + +const getLang = el => { + const x = el.lang || el?.getAttributeNS?.(NS.XML, 'lang') + return x ? x : el.parentElement ? getLang(el.parentElement) : null +} + +const getAlphabet = el => { + const x = el?.getAttributeNS?.(NS.XML, 'lang') + return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null +} + +const getSegmenter = (lang = 'en', granularity = 'word') => { + const segmenter = new Intl.Segmenter(lang, { granularity }) + const granularityIsWord = granularity === 'word' + return function* (strs, makeRange) { + const str = strs.join('') + let name = 0 + let strIndex = -1 + let sum = 0 + for (const { index, segment, isWordLike } of segmenter.segment(str)) { + if (granularityIsWord && !isWordLike) continue + while (sum <= index) sum += strs[++strIndex].length + const startIndex = strIndex + const startOffset = index - (sum - strs[strIndex].length) + const end = index + segment.length - 1 + if (end < str.length) while (sum <= end) sum += strs[++strIndex].length + const endIndex = strIndex + const endOffset = end - (sum - strs[strIndex].length) + 1 + yield [(name++).toString(), + makeRange(startIndex, startOffset, endIndex, endOffset)] + } + } +} + +const fragmentToSSML = (fragment, inherited) => { + const ssml = document.implementation.createDocument(NS.SSML, 'speak') + const { lang } = inherited + if (lang) ssml.documentElement.setAttributeNS(NS.XML, 'lang', lang) + + const convert = (node, parent, inheritedAlphabet) => { + if (!node) return + if (node.nodeType === 3) return ssml.createTextNode(node.textContent) + if (node.nodeType === 4) return ssml.createCDATASection(node.textContent) + if (node.nodeType !== 1 && node.nodeType !== 11) return + + let el + const nodeName = node.nodeName.toLowerCase() + if (nodeName === 'foliate-mark') { + el = ssml.createElementNS(NS.SSML, 'mark') + el.setAttribute('name', node.dataset.name) + } + else if (nodeName === 'br') + el = ssml.createElementNS(NS.SSML, 'break') + else if (nodeName === 'em' || nodeName === 'strong') + el = ssml.createElementNS(NS.SSML, 'emphasis') + + const lang = node.lang || node.getAttributeNS?.(NS.XML, 'lang') + if (lang) { + if (!el) el = ssml.createElementNS(NS.SSML, 'lang') + el.setAttributeNS(NS.XML, 'lang', lang) + } + + const alphabet = node.getAttributeNS?.(NS.SSML, 'alphabet') || inheritedAlphabet + if (!el) { + const ph = node.getAttributeNS?.(NS.SSML, 'ph') + if (ph) { + el = ssml.createElementNS(NS.SSML, 'phoneme') + if (alphabet) el.setAttribute('alphabet', alphabet) + el.setAttribute('ph', ph) + } + } + + if (!el) el = parent + + let child = node.firstChild + while (child) { + const childEl = convert(child, el, alphabet) + if (childEl && el !== childEl) el.append(childEl) + child = child.nextSibling + } + return el + } + convert(fragment, ssml.documentElement, inherited.alphabet) + return ssml +} + +const getFragmentWithMarks = (range, textWalker, granularity) => { + const lang = getLang(range.commonAncestorContainer) + const alphabet = getAlphabet(range.commonAncestorContainer) + + const segmenter = getSegmenter(lang, granularity) + const fragment = range.cloneContents() + + // we need ranges on both the original document (for highlighting) + // and the document fragment (for inserting marks) + // so unfortunately need to do it twice, as you can't copy the ranges + const entries = [...textWalker(range, segmenter)] + const fragmentEntries = [...textWalker(fragment, segmenter)] + + for (const [name, range] of fragmentEntries) { + const mark = document.createElement('foliate-mark') + mark.dataset.name = name + range.insertNode(mark) + } + const ssml = fragmentToSSML(fragment, { lang, alphabet }) + return { entries, ssml } +} + +const rangeIsEmpty = range => !range.toString().trim() + +function* getBlocks(doc) { + let last + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT) + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const name = node.tagName.toLowerCase() + if (blockTags.has(name)) { + if (last) { + last.setEndBefore(node) + if (!rangeIsEmpty(last)) yield last + } + last = doc.createRange() + last.setStart(node, 0) + } + } + if (!last) { + last = doc.createRange() + last.setStart(doc.body.firstChild ?? doc.body, 0) + } + last.setEndAfter(doc.body.lastChild ?? doc.body) + if (!rangeIsEmpty(last)) yield last +} + +class ListIterator { + #arr = [] + #iter + #index = -1 + #f + constructor(iter, f = x => x) { + this.#iter = iter + this.#f = f + } + current() { + if (this.#arr[this.#index]) return this.#f(this.#arr[this.#index]) + } + first() { + const newIndex = 0 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + prev() { + const newIndex = this.#index - 1 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + next() { + const newIndex = this.#index + 1 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + while (true) { + const { done, value } = this.#iter.next() + if (done) break + this.#arr.push(value) + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + } + find(f) { + const index = this.#arr.findIndex(x => f(x)) + if (index > -1) { + this.#index = index + return this.#f(this.#arr[index]) + } + while (true) { + const { done, value } = this.#iter.next() + if (done) break + this.#arr.push(value) + if (f(value)) { + this.#index = this.#arr.length - 1 + return this.#f(value) + } + } + } +} + +export class TTS { + #list + #ranges + #lastMark + #serializer = new XMLSerializer() + constructor(doc, textWalker, highlight, granularity) { + this.doc = doc + this.highlight = highlight + this.#list = new ListIterator(getBlocks(doc), range => { + const { entries, ssml } = getFragmentWithMarks(range, textWalker, granularity) + this.#ranges = new Map(entries) + return [ssml, range] + }) + } + #getMarkElement(doc, mark) { + if (!mark) return null + return doc.querySelector(`mark[name="${CSS.escape(mark)}"`) + } + #speak(doc, getNode) { + if (!doc) return + if (!getNode) return this.#serializer.serializeToString(doc) + const ssml = document.implementation.createDocument(NS.SSML, 'speak') + ssml.documentElement.replaceWith(ssml.importNode(doc.documentElement, true)) + let node = getNode(ssml)?.previousSibling + while (node) { + const next = node.previousSibling ?? node.parentNode?.previousSibling + node.parentNode.removeChild(node) + node = next + } + return this.#serializer.serializeToString(ssml) + } + start() { + this.#lastMark = null + const [doc] = this.#list.first() ?? [] + if (!doc) return this.next() + return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark)) + } + resume() { + const [doc] = this.#list.current() ?? [] + if (!doc) return this.next() + return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark)) + } + prev(paused) { + this.#lastMark = null + const [doc, range] = this.#list.prev() ?? [] + if (paused && range) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + next(paused) { + this.#lastMark = null + const [doc, range] = this.#list.next() ?? [] + if (paused && range) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + from(range) { + this.#lastMark = null + const [doc] = this.#list.find(range_ => + range.compareBoundaryPoints(Range.END_TO_START, range_) <= 0) + let mark + for (const [name, range_] of this.#ranges.entries()) + if (range.compareBoundaryPoints(Range.START_TO_START, range_) <= 0) { + mark = name + break + } + return this.#speak(doc, ssml => this.#getMarkElement(ssml, mark)) + } + setMark(mark) { + const range = this.#ranges.get(mark) + if (range) { + this.#lastMark = mark + this.highlight(range.cloneRange()) + } + } +} diff --git a/static/vendor/foliate-js/ui/menu.js b/static/vendor/foliate-js/ui/menu.js new file mode 100644 index 0000000..d05f074 --- /dev/null +++ b/static/vendor/foliate-js/ui/menu.js @@ -0,0 +1,42 @@ +const createMenuItemRadioGroup = (label, arr, onclick) => { + const group = document.createElement('ul') + group.setAttribute('role', 'group') + group.setAttribute('aria-label', label) + const map = new Map() + const select = value => { + onclick(value) + const item = map.get(value) + for (const child of group.children) + child.setAttribute('aria-checked', child === item ? 'true' : 'false') + } + for (const [label, value] of arr) { + const item = document.createElement('li') + item.setAttribute('role', 'menuitemradio') + item.innerText = label + item.onclick = () => select(value) + map.set(value, item) + group.append(item) + } + return { element: group, select } +} + +export const createMenu = arr => { + const groups = {} + const element = document.createElement('ul') + element.setAttribute('role', 'menu') + const hide = () => element.classList.remove('show') + const hideAnd = f => (...args) => (hide(), f(...args)) + for (const { name, label, type, items, onclick } of arr) { + const widget = type === 'radio' + ? createMenuItemRadioGroup(label, items, hideAnd(onclick)) + : null + if (name) groups[name] = widget + element.append(widget.element) + } + // TODO: keyboard events + window.addEventListener('blur', () => hide()) + window.addEventListener('click', e => { + if (!element.parentNode.contains(e.target)) hide() + }) + return { element, groups } +} diff --git a/static/vendor/foliate-js/ui/tree.js b/static/vendor/foliate-js/ui/tree.js new file mode 100644 index 0000000..7ee85b4 --- /dev/null +++ b/static/vendor/foliate-js/ui/tree.js @@ -0,0 +1,169 @@ +const createSVGElement = tag => + document.createElementNS('http://www.w3.org/2000/svg', tag) + +const createExpanderIcon = () => { + const svg = createSVGElement('svg') + svg.setAttribute('viewBox', '0 0 13 10') + svg.setAttribute('width', '13') + svg.setAttribute('height', '13') + const polygon = createSVGElement('polygon') + polygon.setAttribute('points', '2 1, 12 1, 7 9') + svg.append(polygon) + return svg +} + +const createTOCItemElement = (list, map, onclick) => { + let count = 0 + const makeID = () => `toc-element-${count++}` + const createItem = ({ label, href, subitems }, depth = 0) => { + const a = document.createElement(href ? 'a' : 'span') + a.innerText = label + a.setAttribute('role', 'treeitem') + a.tabIndex = -1 + a.style.paddingInlineStart = `${(depth + 1) * 24}px` + list.push(a) + if (href) { + if (!map.has(href)) map.set(href, a) + a.href = href + a.onclick = event => { + event.preventDefault() + onclick(href) + } + } else a.onclick = event => a.firstElementChild?.onclick(event) + + const li = document.createElement('li') + li.setAttribute('role', 'none') + li.append(a) + if (subitems?.length) { + a.setAttribute('aria-expanded', 'false') + const expander = createExpanderIcon() + expander.onclick = event => { + event.preventDefault() + event.stopPropagation() + const expanded = a.getAttribute('aria-expanded') + a.setAttribute('aria-expanded', expanded === 'true' ? 'false' : 'true') + } + a.prepend(expander) + const ol = document.createElement('ol') + ol.id = makeID() + ol.setAttribute('role', 'group') + a.setAttribute('aria-owns', ol.id) + ol.replaceChildren(...subitems.map(item => createItem(item, depth + 1))) + li.append(ol) + } + return li + } + return createItem +} + +// https://www.w3.org/TR/wai-aria-practices-1.2/examples/treeview/treeview-navigation.html +export const createTOCView = (toc, onclick) => { + const $toc = document.createElement('ol') + $toc.setAttribute('role', 'tree') + const list = [] + const map = new Map() + const createItem = createTOCItemElement(list, map, onclick) + $toc.replaceChildren(...toc.map(item => createItem(item))) + + const isTreeItem = item => item?.getAttribute('role') === 'treeitem' + const getParents = function* (el) { + for (let parent = el.parentNode; parent !== $toc; parent = parent.parentNode) { + const item = parent.previousElementSibling + if (isTreeItem(item)) yield item + } + } + + let currentItem, currentVisibleParent + $toc.addEventListener('focusout', () => { + if (!currentItem) return + // reset parent focus from last time + if (currentVisibleParent) currentVisibleParent.tabIndex = -1 + // if current item is visible, let it have the focus + if (currentItem.offsetParent) { + currentItem.tabIndex = 0 + return + } + // current item is hidden; give focus to the nearest visible parent + for (const item of getParents(currentItem)) { + if (item.offsetParent) { + item.tabIndex = 0 + currentVisibleParent = item + break + } + } + }) + + const setCurrentHref = href => { + if (currentItem) { + currentItem.removeAttribute('aria-current') + currentItem.tabIndex = -1 + } + const el = map.get(href) + if (!el) { + currentItem = list[0] + currentItem.tabIndex = 0 + return + } + for (const item of getParents(el)) + item.setAttribute('aria-expanded', 'true') + el.setAttribute('aria-current', 'page') + el.tabIndex = 0 + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + currentItem = el + } + + const acceptNode = node => isTreeItem(node) && node.offsetParent + ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP + const iter = document.createTreeWalker($toc, 1, { acceptNode }) + const getIter = current => (iter.currentNode = current, iter) + + for (const el of list) el.addEventListener('keydown', event => { + let stop = false + const { currentTarget, key } = event + switch (key) { + case ' ': + case 'Enter': + currentTarget.click() + stop = true + break + case 'ArrowDown': + getIter(currentTarget).nextNode()?.focus() + stop = true + break + case 'ArrowUp': + getIter(currentTarget).previousNode()?.focus() + stop = true + break + case 'ArrowLeft': + if (currentTarget.getAttribute('aria-expanded') === 'true') + currentTarget.setAttribute('aria-expanded', 'false') + else getParents(currentTarget).next()?.value?.focus() + stop = true + break + case 'ArrowRight': + if (currentTarget.getAttribute('aria-expanded') === 'true') + getIter(currentTarget).nextNode()?.focus() + else if (currentTarget.getAttribute('aria-owns')) + currentTarget.setAttribute('aria-expanded', 'true') + stop = true + break + case 'Home': + list[0].focus() + stop = true + break + case 'End': { + const last = list[list.length - 1] + if (last.offsetParent) last.focus() + else getIter(last).previousNode()?.focus() + stop = true + break + } + } + if (stop) { + event.preventDefault() + event.stopPropagation() + } + }) + + return { element: $toc, setCurrentHref } +} diff --git a/static/vendor/foliate-js/uri-template.js b/static/vendor/foliate-js/uri-template.js new file mode 100644 index 0000000..d66bbf7 --- /dev/null +++ b/static/vendor/foliate-js/uri-template.js @@ -0,0 +1,52 @@ +// URI Template: https://datatracker.ietf.org/doc/html/rfc6570 + +const regex = /{([+#./;?&])?([^}]+?)}/g +const varspecRegex = /(.+?)(\*|:[1-9]\d{0,3})?$/ + +const table = { + undefined: { first: '', sep: ',' }, + '+': { first: '', sep: ',', allowReserved: true }, + '.': { first: '.', sep: '.' }, + '/': { first: '/', sep: '/' }, + ';': { first: ';', sep: ';', named: true, ifemp: '' }, + '?': { first: '?', sep: '&', named: true, ifemp: '=' }, + '&': { first: '&', sep: '&', named: true, ifemp: '=' }, + '#': { first: '&', sep: '&', allowReserved: true }, +} + +// 2.4.1 Prefix Values, "Note that this numbering is in characters, not octets" +const prefix = (maxLength, str) => { + let result = '' + for (const char of str) { + const newResult = char + if (newResult.length > maxLength) return result + else result = newResult + } + return result +} + +export const replace = (str, map) => str.replace(regex, (_, operator, variableList) => { + const { first, sep, named, ifemp, allowReserved } = table[operator] + // TODO: this isn't spec compliant + const encode = allowReserved ? encodeURI : encodeURIComponent + const values = variableList.split(',').map(varspec => { + const match = varspec.match(varspecRegex) + if (!match) return + const [, name, modifier] = match + let value = map.get(name) + if (modifier?.startsWith(':')) { + const maxLength = parseInt(modifier.slice(1)) + value = prefix(maxLength, value) + } + return [name, value ? encode(value) : null] + }) + if (!values.filter(([, value]) => value).length) return '' + return first + values + .map(([name, value]) => value + ? (named ? name + (value ? '=' + value : ifemp) : value) : '') + .filter(x => x).join(sep) +}) + +export const getVariables = str => new Set(Array.from(str.matchAll(regex), + ([,, variableList]) => variableList.split(',') + .map(varspec => varspec.match(varspecRegex)?.[1])).flat()) diff --git a/static/vendor/foliate-js/vendor/fflate.js b/static/vendor/foliate-js/vendor/fflate.js new file mode 100644 index 0000000..fab2b3e --- /dev/null +++ b/static/vendor/foliate-js/vendor/fflate.js @@ -0,0 +1 @@ +var r=Uint8Array,n=Uint16Array,a=Int32Array,e=new r([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),i=new r([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),t=new r([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),f=function(r,e){for(var i=new n(31),t=0;t<31;++t)i[t]=e+=1<>1|(21845&d)<<1;w=(61680&(w=(52428&w)>>2|(13107&w)<<2))>>4|(3855&w)<<4,c[d]=((65280&w)>>8|(255&w)<<8)>>1}var b=function(r,a,e){for(var i=r.length,t=0,f=new n(a);t>l]=u}else for(o=new n(i),t=0;t>15-r[t]);return o},s=new r(288);for(d=0;d<144;++d)s[d]=8;for(d=144;d<256;++d)s[d]=9;for(d=256;d<280;++d)s[d]=7;for(d=280;d<288;++d)s[d]=8;var h=new r(32);for(d=0;d<32;++d)h[d]=5;var y=b(s,9,1),g=b(h,5,1),p=function(r){for(var n=r[0],a=1;an&&(n=r[a]);return n},k=function(r,n,a){var e=n/8|0;return(r[e]|r[e+1]<<8)>>(7&n)&a},m=function(r,n){var a=n/8|0;return(r[a]|r[a+1]<<8|r[a+2]<<16)>>(7&n)},x=function(r){return(r+7)/8|0},T=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],E=function(r,n,a){var e=new Error(n||T[r]);if(e.code=r,Error.captureStackTrace&&Error.captureStackTrace(e,E),!a)throw e;return e},z=function(n,a,f,o){var l=n.length,c=o?o.length:0;if(!l||a.f&&!a.l)return f||new r(0);var d=!f,w=d||2!=a.i,s=a.i;d&&(f=new r(3*l));var h=function(n){var a=f.length;if(n>a){var e=new r(Math.max(2*a,n));e.set(f),f=e}},T=a.f||0,z=a.p||0,A=a.b||0,U=a.l,D=a.d,F=a.m,M=a.n,S=8*l;do{if(!U){T=k(n,z,1);var I=k(n,z+1,3);if(z+=3,!I){var O=n[(P=x(z)+4)-4]|n[P-3]<<8,j=P+O;if(j>l){s&&E(0);break}w&&h(A+O),f.set(n.subarray(P,j),A),a.b=A+=O,a.p=z=8*j,a.f=T;continue}if(1==I)U=y,D=g,F=9,M=5;else if(2==I){var q=k(n,z,31)+257,B=k(n,z+10,15)+4,C=q+k(n,z+5,31)+1;z+=14;for(var G=new r(C),H=new r(19),J=0;J>4)<16)G[J++]=P;else{var R=0,V=0;for(16==P?(V=3+k(n,z,3),z+=2,R=G[J-1]):17==P?(V=3+k(n,z,7),z+=3):18==P&&(V=11+k(n,z,127),z+=7);V--;)G[J++]=R}}var W=G.subarray(0,q),X=G.subarray(q);F=p(W),M=p(X),U=b(W,F,1),D=b(X,M,1)}else E(1);if(z>S){s&&E(0);break}}w&&h(A+131072);for(var Y=(1<>4;if((z+=15&R)>S){s&&E(0);break}if(R||E(2),_<256)f[A++]=_;else{if(256==_){$=z,U=null;break}var rr=_-254;if(_>264){var nr=e[J=_-257];rr=k(n,z,(1<>4;ar||E(3),z+=15&ar;X=u[er];if(er>3){nr=i[er];X+=m(n,z)&(1<S){s&&E(0);break}w&&h(A+131072);var ir=A+rr;if(An.length)&&(e=n.length),new r(n.subarray(a,e))}(f,0,A):f.subarray(0,A)},A=new r(0);function U(r,n){return z(r.subarray((a=r,e=n&&n.dictionary,(8!=(15&a[0])||a[0]>>4>7||(a[0]<<8|a[1])%31)&&E(6,"invalid zlib data"),(a[1]>>5&1)==+!e&&E(6,"invalid zlib data: "+(32&a[1]?"need":"unexpected")+" dictionary"),2+(a[1]>>3&4)),-4),{i:2},n&&n.out,n&&n.dictionary);var a,e}var D="undefined"!=typeof TextDecoder&&new TextDecoder;try{D.decode(A,{stream:!0})}catch(r){}export{U as unzlibSync}; diff --git a/static/vendor/foliate-js/vendor/zip.js b/static/vendor/foliate-js/vendor/zip.js new file mode 100644 index 0000000..564d9ec --- /dev/null +++ b/static/vendor/foliate-js/vendor/zip.js @@ -0,0 +1 @@ +const e=4294967295,t=65535,r=134695760,n=r,s=33639248,a=101075792,i=22,o=16384,c=void 0,l=1/0,u="undefined",d="function";let f=2;try{typeof navigator!=u&&navigator.hardwareConcurrency&&(f=navigator.hardwareConcurrency)}catch{}const h={workerURI:"./core/web-worker-wasm.js",wasmURI:"./core/streams/zlib-wasm/zlib-streams.wasm",chunkSize:65536,maxWorkers:f,terminateWorkerTimeout:5e3,useWebWorkers:!0,useCompressionStream:!0,CompressionStream:typeof CompressionStream!=u&&CompressionStream,DecompressionStream:typeof DecompressionStream!=u&&DecompressionStream},p=Object.assign({},h);function w(e){const{baseURI:t,chunkSize:r,maxWorkers:n,terminateWorkerTimeout:s,useCompressionStream:a,useWebWorkers:i,CompressionStream:o,DecompressionStream:c,CompressionStreamZlib:l,DecompressionStreamZlib:u,workerURI:d,wasmURI:f}=e;g("baseURI",t),g("wasmURI",f),g("workerURI",d),g("chunkSize",r),g("maxWorkers",n),g("terminateWorkerTimeout",s),g("useCompressionStream",a),g("useWebWorkers",i),g("CompressionStream",o),g("DecompressionStream",c),g("CompressionStreamZlib",l),g("DecompressionStreamZlib",u)}function g(e,t){t!==c&&(p[e]=t)}const m=[];for(let e=0;e<256;e++){let t=e;for(let e=0;e<8;e++)1&t?t=t>>>1^3988292384:t>>>=1;m[e]=t}class y{constructor(e){this.crc=e||-1}append(e){let t=0|this.crc;for(let r=0,n=0|e.length;r>>8^m[255&(t^e[r])];this.crc=t}get(){return~this.crc}}class b extends TransformStream{constructor(){let e;const t=new y;super({transform(e,r){t.append(e),r.enqueue(e)},flush(){const r=new Uint8Array(4);new DataView(r.buffer).setUint32(0,t.get()),e.value=r}}),e=this}}const S={concat(e,t){if(0===e.length||0===t.length)return e.concat(t);const r=e[e.length-1],n=S.getPartial(r);return 32===n?e.concat(t):S._shiftRight(t,n,0|r,e.slice(0,e.length-1))},bitLength(e){const t=e.length;if(0===t)return 0;const r=e[t-1];return 32*(t-1)+S.getPartial(r)},clamp(e,t){if(32*e.length0&&t&&(e[r-1]=S.partial(t,e[r-1]&2147483648>>t-1,1)),e},partial:(e,t,r)=>32===e?t:(r?0|t:t<<32-e)+1099511627776*e,getPartial:e=>Math.round(e/1099511627776)||32,_shiftRight(e,t,r,n){for(void 0===n&&(n=[]);t>=32;t-=32)n.push(r),r=0;if(0===t)return n.concat(e);for(let s=0;s>>t),r=e[s]<<32-t;const s=e.length?e[e.length-1]:0,a=S.getPartial(s);return n.push(S.partial(t+a&31,t+a>32?r:n.pop(),1)),n}},k={bytes:{fromBits(e){const t=S.bitLength(e)/8,r=new Uint8Array(t);let n;for(let s=0;s>>24,n<<=8;return r},toBits(e){const t=[];let r,n=0;for(r=0;r9007199254740991)throw new Error("Cannot hash more than 2^53 - 1 bits");const a=new Uint32Array(r);let i=0;for(let e=t.blockSize+n-(t.blockSize+n&t.blockSize-1);e<=s;e+=t.blockSize)t._block(a.subarray(16*i,16*(i+1))),i+=1;return r.splice(0,16*i),t}finalize(){const e=this;let t=e._buffer;const r=e._h;t=S.concat(t,[S.partial(1,1)]);for(let e=t.length+2;15&e;e++)t.push(0);for(t.push(Math.floor(e._length/4294967296)),t.push(0|e._length);t.length;)e._block(t.splice(0,16));return e.reset(),r}_f(e,t,r,n){return e<=19?t&r|~t&n:e<=39?t^r^n:e<=59?t&r|t&n|r&n:e<=79?t^r^n:void 0}_S(e,t){return t<>>32-e}_block(e){const t=this,r=t._h,n=Array(80);for(let t=0;t<16;t++)n[t]=e[t];let s=r[0],a=r[1],i=r[2],o=r[3],c=r[4];for(let e=0;e<=79;e++){e>=16&&(n[e]=t._S(1,n[e-3]^n[e-8]^n[e-14]^n[e-16]));const r=t._S(5,s)+t._f(e,a,i,o)+c+n[e]+t._key[Math.floor(e/20)]|0;c=o,o=i,i=t._S(30,a),a=s,s=r}r[0]=r[0]+s|0,r[1]=r[1]+a|0,r[2]=r[2]+i|0,r[3]=r[3]+o|0,r[4]=r[4]+c|0}}},x={aes:class{constructor(e){const t=this;t._tables=[[[],[],[],[],[]],[[],[],[],[],[]]],t._tables[0][0][0]||t._precompute();const r=t._tables[0][4],n=t._tables[1],s=e.length;let a,i,o,c=1;if(4!==s&&6!==s&&8!==s)throw new Error("invalid aes key size");for(t._key=[i=e.slice(0),o=[]],a=s;a<4*s+28;a++){let e=i[a-1];(a%s===0||8===s&&a%s===4)&&(e=r[e>>>24]<<24^r[e>>16&255]<<16^r[e>>8&255]<<8^r[255&e],a%s===0&&(e=e<<8^e>>>24^c<<24,c=c<<1^283*(c>>7))),i[a]=i[a-s]^e}for(let e=0;a;e++,a--){const t=i[3&e?a:a-4];o[e]=a<=4||e<4?t:n[0][r[t>>>24]]^n[1][r[t>>16&255]]^n[2][r[t>>8&255]]^n[3][r[255&t]]}}encrypt(e){return this._crypt(e,0)}decrypt(e){return this._crypt(e,1)}_precompute(){const e=this._tables[0],t=this._tables[1],r=e[4],n=t[4],s=[],a=[];let i,o,c,l;for(let e=0;e<256;e++)a[(s[e]=e<<1^283*(e>>7))^e]=e;for(let u=i=0;!r[u];u^=o||1,i=a[i]||1){let a=i^i<<1^i<<2^i<<3^i<<4;a=a>>8^255&a^99,r[u]=a,n[a]=u,l=s[c=s[o=s[u]]];let d=16843009*l^65537*c^257*o^16843008*u,f=257*s[a]^16843008*a;for(let r=0;r<4;r++)e[r][u]=f=f<<24^f>>>8,t[r][a]=d=d<<24^d>>>8}for(let r=0;r<5;r++)e[r]=e[r].slice(0),t[r]=t[r].slice(0)}_crypt(e,t){if(4!==e.length)throw new Error("invalid aes block size");const r=this._key[t],n=r.length/4-2,s=[0,0,0,0],a=this._tables[t],i=a[0],o=a[1],c=a[2],l=a[3],u=a[4];let d,f,h,p=e[0]^r[0],w=e[t?3:1]^r[1],g=e[2]^r[2],m=e[t?1:3]^r[3],y=4;for(let e=0;e>>24]^o[w>>16&255]^c[g>>8&255]^l[255&m]^r[y],f=i[w>>>24]^o[g>>16&255]^c[m>>8&255]^l[255&p]^r[y+1],h=i[g>>>24]^o[m>>16&255]^c[p>>8&255]^l[255&w]^r[y+2],m=i[m>>>24]^o[p>>16&255]^c[w>>8&255]^l[255&g]^r[y+3],y+=4,p=d,w=f,g=h;for(let e=0;e<4;e++)s[t?3&-e:e]=u[p>>>24]<<24^u[w>>16&255]<<16^u[g>>8&255]<<8^u[255&m]^r[y++],d=p,p=w,w=g,g=m,m=d;return s}}},U={getRandomValues(e){const t=new Uint32Array(e.buffer),r=e=>{let t=987654321;const r=4294967295;return function(){t=36969*(65535&t)+(t>>16)&r;return(((t<<16)+(e=18e3*(65535&e)+(e>>16)&r)&r)/4294967296+.5)*(Math.random()>.5?1:-1)}};for(let n,s=0;s>24))e+=1<<24;else{let t=e>>16&255,r=e>>8&255,n=255&e;255===t?(t=0,255===r?(r=0,255===n?n=0:++n):++r):++t,e=0,e+=t<<16,e+=r<<8,e+=n}return e}incCounter(e){0===(e[0]=this.incWord(e[0]))&&(e[1]=this.incWord(e[1]))}calculate(e,t,r){let n;if(!(n=t.length))return[];const s=S.bitLength(t);for(let s=0;snew v.hmacSha1(k.bytes.toBits(e)),pbkdf2(e,t,r,n){if(r=r||1e4,n<0||r<0)throw new Error("invalid params to pbkdf2");const s=1+(n>>5)<<2;let a,i,o,c,l;const u=new ArrayBuffer(s),d=new DataView(u);let f=0;const h=S;for(t=k.bytes.toBits(t),l=1;f<(s||1);l++){for(a=i=e.encrypt(h.concat(t,[l])),o=1;os&&(e=(new r).update(e).finalize());for(let t=0;tthis.resolveReady=e),password:ee(e,t),signed:r,strength:n-1,pending:new Uint8Array})},async transform(e,t){const r=this,{password:n,strength:a,resolveReady:i,ready:o}=r;n?(await async function(e,t,r,n){const s=await $(e,t,r,re(n,0,j[t])),a=re(n,j[t]);if(s[0]!=a[0]||s[1]!=a[1])throw new Error(_)}(r,a,n,re(e,0,j[a]+2)),e=re(e,j[a]+2),s?t.error(new Error(O)):i()):await o;const c=new Uint8Array(e.length-B-(e.length-B)%T);t.enqueue(Y(r,e,c,0,B,!0))},async flush(e){const{signed:t,ctr:r,hmac:n,pending:s,ready:a}=this;if(n&&r){await a;const i=re(s,0,s.length-B),o=re(s,s.length-B);let c=new Uint8Array;if(i.length){const e=se(Z,i);n.update(e);const t=r.update(e);c=ne(Z,t)}if(t){const e=re(ne(Z,n.digest()),0,B);for(let t=0;tthis.resolveReady=e),password:ee(e,t),strength:r-1,pending:new Uint8Array})},async transform(e,t){const r=this,{password:n,strength:s,resolveReady:a,ready:i}=r;let o=new Uint8Array;n?(o=await async function(e,t,r){const n=E(new Uint8Array(j[t])),s=await $(e,t,r,n);return te(n,s)}(r,s,n),a()):await i;const c=new Uint8Array(o.length+e.length-e.length%T);c.set(o,0),t.enqueue(Y(r,e,c,o.length,0))},async flush(e){const{ctr:t,hmac:r,pending:s,ready:a}=this;if(r&&t){await a;let i=new Uint8Array;if(s.length){const e=t.update(se(Z,s));r.update(e),i=ne(Z,e)}n.signature=ne(Z,r.digest()).slice(0,B),e.enqueue(te(i,n.signature))}}}),n=this}}function Y(e,t,r,n,s,a){const{ctr:i,hmac:o,pending:c}=e,l=t.length-s;let u;for(c.length&&(t=te(c,t),r=function(e,t){if(t&&t>e.length){const r=e;(e=new Uint8Array(t)).set(r,0)}return e}(r,l-l%T)),u=0;u<=l-T;u+=T){const e=se(Z,re(t,u,u+T));a&&o.update(e);const s=i.update(e);a||o.update(s),r.set(ne(Z,s),u+n)}return e.pending=re(t,u),r}async function $(e,t,r,n){e.password=null;const s=await async function(e,t,r,n,s){if(!G)return v.importKey(t);try{return await V.importKey(e,t,r,n,s)}catch{return G=!1,v.importKey(t)}}("raw",r,R,!1,M),a=await async function(e,t,r){if(!J)return v.pbkdf2(t,e.salt,W.iterations,r);try{return await V.deriveBits(e,t,r)}catch{return J=!1,v.pbkdf2(t,e.salt,W.iterations,r)}}(Object.assign({salt:n},W),s,8*(2*I[t]+2)),i=new Uint8Array(a),o=se(Z,re(i,0,I[t])),c=se(Z,re(i,I[t],2*I[t])),l=re(i,2*I[t]);return Object.assign(e,{keys:{key:o,authentication:c,passwordVerification:l},ctr:new H(new q(o),Array.from(P)),hmac:new K(c)}),l}function ee(e,t){return t===c?function(e){if(typeof TextEncoder==u){e=unescape(encodeURIComponent(e));const t=new Uint8Array(e.length);for(let r=0;r>>24]),s=~e.crcKey2.get(),e.keys=[r,n,s]}function de(e){const t=2|e.keys[2];return fe(Math.imul(t,1^t)>>>8)}function fe(e){return 255&e}function he(e){return 4294967295&e}const pe="Invalid uncompressed size";class we extends TransformStream{constructor(e,{chunkSize:t,CompressionStreamZlib:r,CompressionStream:n}){super({});const{compressed:s,encrypted:a,useCompressionStream:i,zipCrypto:o,signed:c,level:l}=e,u=this;let d,f,h=super.readable;a&&!o||!c||(d=new b,h=be(h,d)),s&&(h=ye(h,i,{level:l,chunkSize:t},n,r,n)),a&&(o?h=be(h,new ie(e)):(f=new X(e),h=be(h,f))),me(u,h,()=>{let e;a&&!o&&(e=f.signature),a&&!o||!c||(e=new DataView(d.value.buffer).getUint32(0)),u.signature=e})}}class ge extends TransformStream{constructor(e,{chunkSize:t,DecompressionStreamZlib:r,DecompressionStream:n}){super({});const{zipCrypto:s,encrypted:a,signed:i,signature:o,compressed:c,useCompressionStream:l,deflate64:u}=e;let d,f,h=super.readable;a&&(s?h=be(h,new ae(e)):(f=new Q(e),h=be(h,f))),c&&(h=ye(h,l,{chunkSize:t,deflate64:u},n,r,n)),a&&!s||!i||(d=new b,h=be(h,d)),me(this,h,()=>{if((!a||s)&&i){const e=new DataView(d.value.buffer);if(o!=e.getUint32(0,!1))throw new Error(F)}})}}function me(e,t,r){t=be(t,new TransformStream({flush:r})),Object.defineProperty(e,"readable",{get:()=>t})}function ye(e,t,r,n,s,a){const i=t&&n?n:s||a,o=r.deflate64?"deflate64-raw":"deflate-raw";try{e=be(e,new i(o,r))}catch(n){if(!t)throw n;if(s)e=be(e,new s(o,r));else{if(!a)throw n;e=be(e,new a(o,r))}}return e}function be(e,t){return e.pipeThrough(t)}const Se="start",ke="pull",ze="data",xe="close",Ue="inflate";class Ae extends TransformStream{constructor(e,t){super({});const r=this,{codecType:n}=e;let s;n.startsWith("deflate")?s=we:n.startsWith(Ue)&&(s=ge),r.outputSize=0;let a=0;const i=new s(e,t),o=super.readable,l=new TransformStream({transform(e,t){e&&e.length&&(a+=e.length,t.enqueue(e))},flush(){Object.assign(r,{inputSize:a})}}),u=new TransformStream({transform(t,n){if(t&&t.length&&(n.enqueue(t),r.outputSize+=t.length,e.outputSize!==c&&r.outputSize>e.outputSize))throw new Error(pe)},flush(){const{signature:e}=i;Object.assign(r,{signature:e,inputSize:a})}});Object.defineProperty(r,"readable",{get:()=>o.pipeThrough(l).pipeThrough(i).pipeThrough(u)})}}class ve extends TransformStream{constructor(e){let t;super({transform:function r(n,s){if(t){const e=new Uint8Array(t.length+n.length);e.set(t),e.set(n,t.length),n=e,t=null}n.length>e?(s.enqueue(n.slice(0,e)),r(n.slice(e),s)):t=n},flush(e){t&&t.length&&e.enqueue(t)}})}}const De={type:"module"};let _e,Fe,Oe,Ee=!0;try{Ee=typeof structuredClone==d&&structuredClone(new DOMException("","AbortError")).code!==c}catch{}class Te{constructor(e,{readable:t,writable:r},{options:n,config:s,streamOptions:a,useWebWorkers:i,transferStreams:o,workerURI:l},d){const{signal:f}=a;return Object.assign(e,{busy:!0,readable:t.pipeThrough(new ve(s.chunkSize)).pipeThrough(new Ce(a),{signal:f}),writable:r,options:Object.assign({},n),workerURI:l,transferStreams:o,terminate:()=>new Promise(t=>{const{worker:r,busy:n}=e;r?(n?e.resolveTerminated=t:(r.terminate(),t()),e.interface=null):t()}),onTaskFinished(){const{resolveTerminated:t}=e;t&&(e.resolveTerminated=null,e.terminated=!0,e.worker.terminate(),t()),e.busy=!1,d(e)}}),_e===c&&(_e=typeof Worker!=u),(i&&_e?Me:We)(e,s)}}class Ce extends TransformStream{constructor({onstart:e,onprogress:t,size:r,onend:n}){let s=0;super({async start(){e&&await Re(e,r)},async transform(e,n){s+=e.length,t&&await Re(t,s,r),n.enqueue(e)},async flush(){n&&await Re(n,s)}})}}async function Re(e,...t){try{await e(...t)}catch{}}function We(e,t){return{run:()=>async function({options:e,readable:t,writable:r,onTaskFinished:n},s){let a;try{if(!e.useCompressionStream)try{await void 0}catch{e.useCompressionStream=!0}a=new Ae(e,s),await t.pipeThrough(a).pipeTo(r,{preventClose:!0,preventAbort:!0});const{signature:n,inputSize:i,outputSize:o}=a;return{signature:n,inputSize:i,outputSize:o}}catch(e){throw a&&(e.outputSize=a.outputSize),e}finally{n()}}(e,t)}}function Me(e,t){const{baseURI:r,chunkSize:n}=t;let{wasmURI:s}=t;if(!e.interface){let a;typeof s==d&&(s=s());try{a=je(e.workerURI,r,e)}catch{return _e=!1,We(e,t)}Object.assign(e,{worker:a,interface:{run:()=>async function(e,t){let r,n;const s=new Promise((e,t)=>{r=e,n=t});Object.assign(e,{reader:null,writer:null,resolveResult:r,rejectResult:n,result:s});const{readable:a,options:i}=e,{writable:o,closed:c}=function(e){const{writable:t,readable:r}=new TransformStream,n=r.pipeTo(e,{preventClose:!0});return{writable:t,closed:n}}(e.writable),l=Ie({type:Se,options:i,config:t,readable:a,writable:o},e);l||Object.assign(e,{reader:a.getReader(),writer:o.getWriter()});const u=await s;l||await o.getWriter().close();return await c,u}(e,{chunkSize:n,wasmURI:s,baseURI:r})}})}return e.interface}function je(e,t,r,n,s=!0){let a,i,o;if(Fe===c){const l=typeof e==d;i=l?e(s):e;const u=i.startsWith("data:"),f=i.startsWith("blob:");if(u||f){n===c&&(n=!1),n&&(o=De);try{a=new Worker(i,o)}catch(s){if(f)try{URL.revokeObjectURL(i)}catch{}if(l&&f)return je(e,t,r,n,!1);if(n)throw s;return je(e,t,r,!0,!1)}}else{n===c&&(n=!0),n&&(o=De);try{i=new URL(i,t)}catch{}try{a=new Worker(i,o)}catch(a){if(n)throw a;return je(e,t,r,!1,s)}}Fe=i,Oe=o}else a=new Worker(Fe,Oe);return a.addEventListener("message",e=>async function({data:e},t){const{type:r,value:n,messageId:s,result:a,error:i}=e,{reader:o,writer:c,resolveResult:l,rejectResult:u,onTaskFinished:d}=t;try{if(i){const{message:e,stack:t,code:r,name:n,outputSize:s}=i,a=new Error(e);Object.assign(a,{stack:t,code:r,name:n,outputSize:s}),f(a)}else{if(r==ke){const{value:e,done:r}=await o.read();Ie({type:ze,value:e,done:r,messageId:s},t)}r==ze&&(await c.ready,await c.write(new Uint8Array(n)),Ie({type:"ack",messageId:s},t)),r==xe&&f(null,a)}}catch(i){Ie({type:xe,messageId:s},t),f(i)}function f(e,t){e?u(e):l(t),c&&c.releaseLock(),d()}}(e,r)),a}function Ie(e,{worker:t,writer:r,onTaskFinished:n,transferStreams:s}){try{const{value:r,readable:n,writable:a}=e,i=[];if(r&&(e.value=r,i.push(e.value.buffer)),s&&Ee?(n&&i.push(n),a&&i.push(a)):e.readable=e.writable=null,i.length)try{return t.postMessage(e,i),!0}catch{Ee=!1,e.readable=e.writable=null,t.postMessage(e)}else t.postMessage(e)}catch(e){throw r&&r.releaseLock(),n(),e}}let Be=[];const Pe=[];let Le=0;async function Ve(e,t){const{options:r,config:n}=t,{transferStreams:s,useWebWorkers:a,useCompressionStream:i,compressed:o,signed:l,encrypted:u}=r,{workerURI:d,maxWorkers:f}=n;t.transferStreams=s||s===c;const h=!(o||l||u||t.transferStreams);return t.useWebWorkers=!h&&(a||a===c&&n.useWebWorkers),t.workerURI=t.useWebWorkers&&d?d:c,r.useCompressionStream=i||i===c&&n.useCompressionStream,(await async function(){const r=Be.find(e=>!e.busy);if(r)return Ne(r),new Te(r,e,t,p);if(Be.lengthPe.push({resolve:r,stream:e,workerOptions:t}))}()).run();function p(e){if(Pe.length){const[{resolve:t,stream:r,workerOptions:n}]=Pe.splice(0,1);t(new Te(e,r,n,p))}else e.worker?(Ne(e),function(e,t){const{config:r}=t,{terminateWorkerTimeout:n}=r;Number.isFinite(n)&&n>=0&&(e.terminated?e.terminated=!1:e.terminateTimeout=setTimeout(async()=>{Be=Be.filter(t=>t!=e);try{await e.terminate()}catch{}},n))}(e,t)):Be=Be.filter(t=>t!=e)}}function Ne(e){const{terminateTimeout:t}=e;t&&(clearTimeout(t),e.terminateTimeout=null)}const Ze=65536,qe="writable";class He{constructor(){this.size=0}init(){this.initialized=!0}}class Ke extends He{get readable(){const e=this,{chunkSize:t=Ze}=e,r=new ReadableStream({start(){this.chunkOffset=0},async pull(n){const{offset:s=0,size:a,diskNumberStart:i}=r,{chunkOffset:o}=this,l=a===c?t:Math.min(t,a-o),u=await rt(e,s+o,l,i);n.enqueue(u),o+t>a||a===c&&!u.length&&l?n.close():this.chunkOffset+=t}});return r}}class Ge extends Ke{constructor(e){super(),Object.assign(this,{blob:e,size:e.size})}async readUint8Array(e,t){const r=this,n=e+t,s=e||nt&&(a=a.slice(e,n)),new Uint8Array(a)}}class Je extends He{constructor(e){super();const t=new TransformStream,r=[];e&&r.push(["Content-Type",e]),Object.defineProperty(this,qe,{get:()=>t.writable}),this.blob=new Response(t.readable,{headers:r}).blob()}getData(){return this.blob}}class Qe extends Je{constructor(e){super(e),Object.assign(this,{encoding:e,utf8:!e||"utf-8"==e.toLowerCase()})}async getData(){const{encoding:e,utf8:t}=this,r=await super.getData();if(r.text&&t)return r.text();{const t=new FileReader;return new Promise((n,s)=>{Object.assign(t,{onload:({target:e})=>n(e.result),onerror:()=>s(t.error)}),t.readAsText(r,e)})}}}class Xe extends Ke{constructor(e){super(),this.readers=e}async init(){const e=this,{readers:t}=e;e.lastDiskNumber=0,e.lastDiskOffset=0,await Promise.all(t.map(async(r,n)=>{await r.init(),n!=t.length-1&&(e.lastDiskOffset+=r.size),e.size+=r.size})),super.init()}async readUint8Array(e,t,r=0){const n=this,{readers:s}=this;let a,i=r;-1==i&&(i=s.length-1);let o=e;for(;s[i]&&o>=s[i].size;)o-=s[i].size,i++;const c=s[i];if(c){const s=c.size;if(o+t<=s)a=await rt(c,o,t);else{const i=s-o;a=new Uint8Array(t);const l=await rt(c,o,i);a.set(l,0);const u=await n.readUint8Array(e+i,t-i,r);a.set(u,i),l.length+u.length=i?(await o(t.subarray(0,i)),await c(),r.diskOffset+=n.size,r.diskNumber++,a=null,await this.write(t.subarray(i))):await o(t);else{const{value:i,done:o}=await e.next();if(o&&!i)throw new Error("Writer iterator completed too soon");n=i,n.size=0,n.maxSize&&(r.maxSize=n.maxSize),r.availableSize=r.maxSize,await tt(n),s=i.writable,a=s.getWriter(),await this.write(t)}},async close(){await a.ready,await c()}});async function o(e){const t=e.length;t&&(await a.ready,await a.write(e),n.size+=t,r.size+=t,r.availableSize-=t)}async function c(){await a.close()}Object.defineProperty(r,qe,{get:()=>i})}}class $e{constructor(e){return Array.isArray(e)&&(e=new Xe(e)),e instanceof ReadableStream&&(e={readable:e}),e}}class et{constructor(e){return e.writable===c&&typeof e.next==d&&(e=new Ye(e)),e instanceof WritableStream&&(e={writable:e}),e.size===c&&(e.size=0),e instanceof Ye||Object.assign(e,{diskNumber:0,diskOffset:0,availableSize:l,maxSize:l}),e}}async function tt(e,t){if(!e.init||e.initialized)return Promise.resolve();await e.init(t)}function rt(e,t,r,n){return e.readUint8Array(t,r,n)}const nt="\0☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼ !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~⌂ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ".split(""),st=256==nt.length;function at(e,t){return t&&"cp437"==t.trim().toLowerCase()?function(e){if(st){let t="";for(let r=0;rthis[t]=e[t])}}const zt="File format is not recognized",xt="Zip64 extra field not found",Ut="Compression method not supported",At="Split zip file",vt="Overlapping entry found",Dt="utf-8",_t="cp437",Ft=[[ut,e],[dt,e],[ft,e],[ht,t]],Ot={[t]:{getValue:Nt,bytes:4},[e]:{getValue:Zt,bytes:8}};class Et{constructor(e,t={}){Object.assign(this,{reader:new $e(e),options:t,config:p,readRanges:[]})}async*getEntriesGenerator(n={}){const l=this;let{reader:u}=l;const{config:d}=l;if(await tt(u),u.size!==c&&u.readUint8Array||(u=new Ge(await new Response(u.readable).blob()),await tt(u)),u.size=0;e--)if(i[e]==a[0]&&i[e+1]==a[1]&&i[e+2]==a[2]&&i[e+3]==a[3])return{offset:s+e,buffer:i.slice(e,e+n).buffer}}}(u,101010256,u.size,i,1048560);if(!f){throw Nt(qt(await rt(u,0,4)))==r?new Error(At):new Error("End of central directory not found")}const h=qt(f);let p=Nt(h,12),w=Nt(h,16);const g=f.offset,m=Vt(h,20),y=g+i+m;let b=Vt(h,4);const S=u.lastDiskNumber||0;let k=Vt(h,6),z=Vt(h,8),x=0,U=0;if(w==e||p==e||z==t||k==t){const r=qt(await rt(u,f.offset-20,20));if(117853008==Nt(r,0)){w=Zt(r,8);let n=await rt(u,w,56,-1),s=qt(n);const i=f.offset-20-56;if(Nt(s,0)!=a&&w!=i){const e=w;w=i,w>e&&(x=w-e),n=await rt(u,w,56,-1),s=qt(n)}if(Nt(s,0)!=a)throw new Error("End of Zip64 central directory locator not found");b==t&&(b=Nt(s,16)),k==t&&(k=Nt(s,20)),z==t&&(z=Zt(s,32)),p==e&&(p=Zt(s,40)),w-=p}}if(w>=u.size&&(x=u.size-w-p-i,w=u.size-p-i),S!=b)throw new Error(At);if(w<0)throw new Error(zt);let A=0,v=await rt(u,w,p,k),D=qt(v);if(p){const e=f.offset-p;if(Nt(D,A)!=s&&w!=e){const t=w;w=e,w>t&&(x+=w-t),v=await rt(u,w,p,k),D=qt(v)}}const _=f.offset-w-(u.lastDiskOffset||0);if(p!=_&&_>=0&&(p=_,v=await rt(u,w,p,k),D=qt(v)),w<0||w>=u.size)throw new Error(zt);const F=It(l,n,"filenameEncoding"),O=It(l,n,"commentEncoding");for(let e=0;e>8),g=p>>8==3,m=v.subarray(i,f),y=Vt(D,A+32),b=h+y,S=v.subarray(h,b),k=a,_=a,E=Nt(D,A+38),T=255&E,C={readOnly:Boolean(1&T),hidden:Boolean(2&T),system:Boolean(4&T),directory:Boolean(16&T),archive:Boolean(32&T)},R=Nt(D,A+42)+x,W=It(l,n,"decodeText")||at,M=k?Dt:F||_t,j=_?Dt:O||_t;let I=W(m,M);I===c&&(I=at(m,M));let B=W(S,j);B===c&&(B=at(S,j)),Object.assign(r,{versionMadeBy:p,msDosCompatible:w,compressedSize:0,uncompressedSize:0,commentLength:y,offset:R,diskNumberStart:Vt(D,A+34),internalFileAttributes:Vt(D,A+36),externalFileAttributes:E,msdosAttributesRaw:T,msdosAttributes:C,rawFilename:m,filenameUTF8:k,commentUTF8:_,rawExtraField:v.subarray(f,h),rawComment:S,filename:I,comment:B}),U=Math.max(R,U),Rt(r,r,D,A+6);const P=r.externalFileAttributes>>16&t;r.unixMode===c&&16877&P&&(r.unixMode=P);const L=Boolean(2048&r.unixMode),V=Boolean(1024&r.unixMode),N=Boolean(512&r.unixMode),Z=r.unixMode!==c?!!(73&r.unixMode):g&&!!(73&P),q=r.unixMode!==c&&(61440&r.unixMode)==o,H=(61440&P)==o;Object.assign(r,{setuid:L,setgid:V,sticky:N,unixExternalUpper:P,internalFileAttribute:r.internalFileAttributes,externalFileAttribute:r.externalFileAttributes,executable:Z,directory:q||H||w&&C.directory||I.endsWith("/")&&!r.uncompressedSize,zipCrypto:r.encrypted&&!r.extraFieldAES});const K=new kt(r);K.getData=(e,t)=>r.getData(e,K,l.readRanges,t),K.arrayBuffer=async e=>{const t=new TransformStream,[n]=await Promise.all([new Response(t.readable).arrayBuffer(),r.getData(t,K,l.readRanges,e)]);return n},A=b;const{onprogress:G}=n;if(G)try{await G(e+1,z,new kt(r))}catch{}yield K}const E=It(l,n,"extractPrependedData"),T=It(l,n,"extractAppendedData");return E&&(l.prependedData=U>0?await rt(u,0,U):new Uint8Array),l.comment=m?await rt(u,g+i,m):new Uint8Array,T&&(l.appendedData=y>>8&255:w>>>24&255),outputSize:U?y:m,signature:w,compressed:0!=f&&!U,encrypted:a.encrypted&&!U,useWebWorkers:It(a,s,"useWebWorkers"),useCompressionStream:N,transferStreams:It(a,s,"transferStreams"),deflate64:V,checkPasswordOnly:M},config:h,streamOptions:{signal:W,size:C,onstart:B,onprogress:P,onend:L}};let q;j&&await async function({reader:e,fileEntry:t,offset:r,diskNumberStart:s,signature:a,compressedSize:i,uncompressedSize:o,dataOffset:c,dataDescriptor:l,extraFieldZip64:u,readRanges:d}){let f=0;if(s)for(let t=0;t=e.start&&p.start>1,dataDescriptor:!(8&~n),languageEncodingFlag:!(2048&~n)},rawLastModDate:a,lastModDate:Bt(a),filenameLength:Vt(t,r+22),extraFieldLength:Vt(t,r+24)})}function Rt(e,t,r,n,s){const{rawExtraField:a}=t,i=t.extraField=new Map,o=qt(new Uint8Array(a));let c=0;try{for(;ct[e]==r);for(let s=0,a=0;s=5&&(a.push(pt),i.push(wt));let o=1;a.forEach((r,s)=>{if(e.data.length>=o+4){const a=Nt(n,o);t[r]=e[r]=new Date(1e3*a);const c=i[s];e[c]=a}o+=4})}(g,t,s),t.extraFieldExtendedTimestamp=g);const m=i.get(6534);m&&(t.extraFieldUSDZ=m)}function Wt(e,t,r,n,s){const a=qt(e.data),i=new y;i.append(s[r]);const o=qt(new Uint8Array(4));o.setUint32(0,i.get(),!0);const c=Nt(a,1);Object.assign(e,{version:Lt(a,0),[t]:at(e.data.subarray(5)),valid:!s.bitFlag.languageEncodingFlag&&c==Nt(o,0)}),e.valid&&(n[t]=e[t],n[t+"UTF8"]=!0)}function Mt(e,t,r){try{const n=qt(new Uint8Array(e.data));let s=0;const a=Lt(n,s++),i=Lt(n,s++),o=e.data.subarray(s,s+i);s+=i;const l=jt(o),u=Lt(n,s++),d=e.data.subarray(s,s+u);s+=u;const f=jt(d);let h=c;if(!r&&s+2<=e.data.length){const t=e.data;h=new DataView(t.buffer,t.byteOffset+s,2).getUint16(0,!0)}Object.assign(e,{version:a,uid:l,gid:f,unixMode:h}),l!==c&&(t.uid=l),f!==c&&(t.gid=f),h!==c&&(t.unixMode=h)}catch{}}function jt(e){const t=new Uint8Array(4);t.set(e,0);return new DataView(t.buffer,t.byteOffset,4).getUint32(0,!0)}function It(e,t,r){return t[r]===c?e.options[r]:t[r]}function Bt(e){const r=(4294901760&e)>>16,n=e&t;try{return new Date(1980+((65024&r)>>9),((480&r)>>5)-1,31&r,(63488&n)>>11,(2016&n)>>5,2*(31&n),0)}catch{}}function Pt(e){return new Date(Number(e/BigInt(1e4)-BigInt(116444736e5)))}function Lt(e,t){return e.getUint8(t)}function Vt(e,t){return e.getUint16(t,!0)}function Nt(e,t){return e.getUint32(t,!0)}function Zt(e,t){return Number(e.getBigUint64(t,!0))}function qt(e){return new DataView(e.buffer)}try{w({baseURI:import.meta.url})}catch{}export{Ge as BlobReader,Je as BlobWriter,Qe as TextWriter,Et as ZipReader,w as configure}; diff --git a/static/vendor/foliate-js/view.js b/static/vendor/foliate-js/view.js new file mode 100644 index 0000000..9bdaf02 --- /dev/null +++ b/static/vendor/foliate-js/view.js @@ -0,0 +1,597 @@ +import * as CFI from './epubcfi.js' +import { TOCProgress, SectionProgress } from './progress.js' +import { Overlayer } from './overlayer.js' +import { textWalker } from './text-walker.js' + +const SEARCH_PREFIX = 'foliate-search:' + +const isZip = async file => { + const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()) + return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 +} + +const isPDF = async file => { + const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()) + return arr[0] === 0x25 + && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46 + && arr[4] === 0x2d +} + +const isCBZ = ({ name, type }) => + type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz') + +const isFB2 = ({ name, type }) => + type === 'application/x-fictionbook+xml' || name.endsWith('.fb2') + +const isFBZ = ({ name, type }) => + type === 'application/x-zip-compressed-fb2' + || name.endsWith('.fb2.zip') || name.endsWith('.fbz') + +const makeZipLoader = async file => { + const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = + await import('./vendor/zip.js') + configure({ useWebWorkers: false }) + const reader = new ZipReader(new BlobReader(file)) + const entries = await reader.getEntries() + const map = new Map(entries.map(entry => [entry.filename, entry])) + const load = f => (name, ...args) => + map.has(name) ? f(map.get(name), ...args) : null + const loadText = load(entry => entry.getData(new TextWriter())) + const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type))) + const getSize = name => map.get(name)?.uncompressedSize ?? 0 + return { entries, loadText, loadBlob, getSize } +} + +const getFileEntries = async entry => entry.isFile ? entry + : (await Promise.all(Array.from( + await new Promise((resolve, reject) => entry.createReader() + .readEntries(entries => resolve(entries), error => reject(error))), + getFileEntries))).flat() + +const makeDirectoryLoader = async entry => { + const entries = await getFileEntries(entry) + const files = await Promise.all( + entries.map(entry => new Promise((resolve, reject) => + entry.file(file => resolve([file, entry.fullPath]), + error => reject(error))))) + const map = new Map(files.map(([file, path]) => + [path.replace(entry.fullPath + '/', ''), file])) + const decoder = new TextDecoder() + const decode = x => x ? decoder.decode(x) : null + const getBuffer = name => map.get(name)?.arrayBuffer() ?? null + const loadText = async name => decode(await getBuffer(name)) + const loadBlob = name => map.get(name) + const getSize = name => map.get(name)?.size ?? 0 + return { loadText, loadBlob, getSize } +} + +export class ResponseError extends Error {} +export class NotFoundError extends Error {} +export class UnsupportedTypeError extends Error {} + +const fetchFile = async url => { + const res = await fetch(url) + if (!res.ok) throw new ResponseError( + `${res.status} ${res.statusText}`, { cause: res }) + return new File([await res.blob()], new URL(res.url).pathname) +} + +export const makeBook = async file => { + if (typeof file === 'string') file = await fetchFile(file) + let book + if (file.isDirectory) { + const loader = await makeDirectoryLoader(file) + const { EPUB } = await import('./epub.js') + book = await new EPUB(loader).init() + } + else if (!file.size) throw new NotFoundError('File not found') + else if (await isZip(file)) { + const loader = await makeZipLoader(file) + if (isCBZ(file)) { + const { makeComicBook } = await import('./comic-book.js') + book = makeComicBook(loader, file) + } + else if (isFBZ(file)) { + const { makeFB2 } = await import('./fb2.js') + const { entries } = loader + const entry = entries.find(entry => entry.filename.endsWith('.fb2')) + const blob = await loader.loadBlob((entry ?? entries[0]).filename) + book = await makeFB2(blob) + } + else { + const { EPUB } = await import('./epub.js') + book = await new EPUB(loader).init() + } + } + else if (await isPDF(file)) { + const { makePDF } = await import('./pdf.js') + book = await makePDF(file) + } + else { + const { isMOBI, MOBI } = await import('./mobi.js') + if (await isMOBI(file)) { + const fflate = await import('./vendor/fflate.js') + book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file) + } + else if (isFB2(file)) { + const { makeFB2 } = await import('./fb2.js') + book = await makeFB2(file) + } + } + if (!book) throw new UnsupportedTypeError('File type not supported') + return book +} + +class CursorAutohider { + #timeout + #el + #check + #state + constructor(el, check, state = {}) { + this.#el = el + this.#check = check + this.#state = state + if (this.#state.hidden) this.hide() + this.#el.addEventListener('mousemove', ({ screenX, screenY }) => { + // check if it actually moved + if (screenX === this.#state.x && screenY === this.#state.y) return + this.#state.x = screenX, this.#state.y = screenY + this.show() + if (this.#timeout) clearTimeout(this.#timeout) + if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000) + }, false) + } + cloneFor(el) { + return new CursorAutohider(el, this.#check, this.#state) + } + hide() { + this.#el.style.cursor = 'none' + this.#state.hidden = true + } + show() { + this.#el.style.removeProperty('cursor') + this.#state.hidden = false + } +} + +class History extends EventTarget { + #arr = [] + #index = -1 + pushState(x) { + const last = this.#arr[this.#index] + if (last === x || last?.fraction && last.fraction === x.fraction) return + this.#arr[++this.#index] = x + this.#arr.length = this.#index + 1 + this.dispatchEvent(new Event('index-change')) + } + replaceState(x) { + const index = this.#index + this.#arr[index] = x + } + back() { + const index = this.#index + if (index <= 0) return + const detail = { state: this.#arr[index - 1] } + this.#index = index - 1 + this.dispatchEvent(new CustomEvent('popstate', { detail })) + this.dispatchEvent(new Event('index-change')) + } + forward() { + const index = this.#index + if (index >= this.#arr.length - 1) return + const detail = { state: this.#arr[index + 1] } + this.#index = index + 1 + this.dispatchEvent(new CustomEvent('popstate', { detail })) + this.dispatchEvent(new Event('index-change')) + } + get canGoBack() { + return this.#index > 0 + } + get canGoForward() { + return this.#index < this.#arr.length - 1 + } + clear() { + this.#arr = [] + this.#index = -1 + } +} + +const languageInfo = lang => { + if (!lang) return {} + try { + const canonical = Intl.getCanonicalLocales(lang)[0] + const locale = new Intl.Locale(canonical) + const isCJK = ['zh', 'ja', 'kr'].includes(locale.language) + const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction + return { canonical, locale, isCJK, direction } + } catch (e) { + console.warn(e) + return {} + } +} + +export class View extends HTMLElement { + #root = this.attachShadow({ mode: 'closed' }) + #sectionProgress + #tocProgress + #pageProgress + #searchResults = new Map() + #searchDraw + #searchDrawOptions + #cursorAutohider = new CursorAutohider(this, () => + this.hasAttribute('autohide-cursor')) + isFixedLayout = false + lastLocation + history = new History() + constructor() { + super() + this.history.addEventListener('popstate', ({ detail }) => { + const resolved = this.resolveNavigation(detail.state) + this.renderer.goTo(resolved) + }) + } + async open(book) { + if (typeof book === 'string' + || typeof book.arrayBuffer === 'function' + || book.isDirectory) book = await makeBook(book) + this.book = book + this.language = languageInfo(book.metadata?.language) + + if (book.splitTOCHref && book.getTOCFragment) { + const ids = book.sections.map(s => s.id) + this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600) + const splitHref = book.splitTOCHref.bind(book) + const getFragment = book.getTOCFragment.bind(book) + this.#tocProgress = new TOCProgress() + await this.#tocProgress.init({ + toc: book.toc ?? [], ids, splitHref, getFragment }) + this.#pageProgress = new TOCProgress() + await this.#pageProgress.init({ + toc: book.pageList ?? [], ids, splitHref, getFragment }) + } + + this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated' + if (this.isFixedLayout) { + await import('./fixed-layout.js') + this.renderer = document.createElement('foliate-fxl') + } else { + await import('./paginator.js') + this.renderer = document.createElement('foliate-paginator') + } + this.renderer.setAttribute('exportparts', 'head,foot,filter') + this.renderer.addEventListener('load', e => this.#onLoad(e.detail)) + this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail)) + this.renderer.addEventListener('create-overlayer', e => + e.detail.attach(this.#createOverlayer(e.detail))) + this.renderer.open(book) + this.#root.append(this.renderer) + + if (book.sections.some(section => section.mediaOverlay)) { + const activeClass = book.media.activeClass + const playbackActiveClass = book.media.playbackActiveClass + this.mediaOverlay = book.getMediaOverlay() + let lastActive + this.mediaOverlay.addEventListener('highlight', e => { + const resolved = this.resolveNavigation(e.detail.text) + this.renderer.goTo(resolved) + .then(() => { + const { doc } = this.renderer.getContents() + .find(x => x.index = resolved.index) + const el = resolved.anchor(doc) + el.classList.add(activeClass) + if (playbackActiveClass) el.ownerDocument + .documentElement.classList.add(playbackActiveClass) + lastActive = new WeakRef(el) + }) + }) + this.mediaOverlay.addEventListener('unhighlight', () => { + const el = lastActive?.deref() + if (el) { + el.classList.remove(activeClass) + if (playbackActiveClass) el.ownerDocument + .documentElement.classList.remove(playbackActiveClass) + } + }) + } + } + close() { + this.renderer?.destroy() + this.renderer?.remove() + this.#sectionProgress = null + this.#tocProgress = null + this.#pageProgress = null + this.#searchResults = new Map() + this.lastLocation = null + this.history.clear() + this.tts = null + this.mediaOverlay = null + } + goToTextStart() { + return this.goTo(this.book.landmarks + ?.find(m => m.type.includes('bodymatter') || m.type.includes('text')) + ?.href ?? this.book.sections.findIndex(s => s.linear !== 'no')) + } + async init({ lastLocation, showTextStart }) { + const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null + if (resolved) { + await this.renderer.goTo(resolved) + this.history.pushState(lastLocation) + } + else if (showTextStart) await this.goToTextStart() + else { + this.history.pushState(0) + await this.next() + } + } + #emit(name, detail, cancelable) { + return this.dispatchEvent(new CustomEvent(name, { detail, cancelable })) + } + #onRelocate({ reason, range, index, fraction, size }) { + const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {} + const tocItem = this.#tocProgress?.getProgress(index, range) + const pageItem = this.#pageProgress?.getProgress(index, range) + const cfi = this.getCFI(index, range) + this.lastLocation = { ...progress, tocItem, pageItem, cfi, range } + if (reason === 'snap' || reason === 'page' || reason === 'scroll') + this.history.replaceState(cfi) + this.#emit('relocate', this.lastLocation) + } + #onLoad({ doc, index }) { + // set language and dir if not already set + doc.documentElement.lang ||= this.language.canonical ?? '' + if (!this.language.isCJK) + doc.documentElement.dir ||= this.language.direction ?? '' + + this.#handleLinks(doc, index) + this.#cursorAutohider.cloneFor(doc.documentElement) + + this.#emit('load', { doc, index }) + } + #handleLinks(doc, index) { + const { book } = this + const section = book.sections[index] + doc.addEventListener('click', e => { + const a = e.target.closest('a[href]') + if (!a) return + e.preventDefault() + const href_ = a.getAttribute('href') + const href = section?.resolveHref?.(href_) ?? href_ + if (book?.isExternal?.(href)) + Promise.resolve(this.#emit('external-link', { a, href_ }, true)) + .then(x => x ? globalThis.open(href_, '_blank') : null) + .catch(e => console.error(e)) + else Promise.resolve(this.#emit('link', { a, href }, true)) + .then(x => x ? this.goTo(href) : null) + .catch(e => console.error(e)) + }) + } + async addAnnotation(annotation, remove) { + const { value } = annotation + if (value.startsWith(SEARCH_PREFIX)) { + const cfi = value.replace(SEARCH_PREFIX, '') + const { index, anchor } = await this.resolveNavigation(cfi) + const obj = this.#getOverlayer(index) + if (obj) { + const { overlayer, doc } = obj + if (remove) { + overlayer.remove(value) + return + } + const range = doc ? anchor(doc) : anchor + overlayer.add(value, range, this.#searchDraw, this.#searchDrawOptions) + } + return + } + const { index, anchor } = await this.resolveNavigation(value) + const obj = this.#getOverlayer(index) + if (obj) { + const { overlayer, doc } = obj + overlayer.remove(value) + if (!remove) { + const range = doc ? anchor(doc) : anchor + const draw = (func, opts) => overlayer.add(value, range, func, opts) + this.#emit('draw-annotation', { draw, annotation, doc, range }) + } + } + const label = this.#tocProgress.getProgress(index)?.label ?? '' + return { index, label } + } + deleteAnnotation(annotation) { + return this.addAnnotation(annotation, true) + } + #getOverlayer(index) { + return this.renderer.getContents() + .find(x => x.index === index && x.overlayer) + } + #createOverlayer({ doc, index }) { + const overlayer = new Overlayer() + doc.addEventListener('click', e => { + const [value, range] = overlayer.hitTest(e) + if (value && !value.startsWith(SEARCH_PREFIX)) { + this.#emit('show-annotation', { value, index, range }) + } + }, false) + + const list = this.#searchResults.get(index) + if (list) for (const item of list) this.addAnnotation(item) + + this.#emit('create-overlay', { index }) + return overlayer + } + async showAnnotation(annotation) { + const { value } = annotation + const resolved = await this.goTo(value) + if (resolved) { + const { index, anchor } = resolved + const { doc } = this.#getOverlayer(index) + const range = anchor(doc) + this.#emit('show-annotation', { value, index, range }) + } + } + getCFI(index, range) { + const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index) + if (!range) return baseCFI + return CFI.joinIndir(baseCFI, CFI.fromRange(range)) + } + resolveCFI(cfi) { + if (this.book.resolveCFI) + return this.book.resolveCFI(cfi) + else { + const parts = CFI.parse(cfi) + const index = CFI.fake.toIndex((parts.parent ?? parts).shift()) + const anchor = doc => CFI.toRange(doc, parts) + return { index, anchor } + } + } + resolveNavigation(target) { + try { + if (typeof target === 'number') return { index: target } + if (typeof target.fraction === 'number') { + const [index, anchor] = this.#sectionProgress.getSection(target.fraction) + return { index, anchor } + } + if (CFI.isCFI.test(target)) return this.resolveCFI(target) + return this.book.resolveHref(target) + } catch (e) { + console.error(e) + console.error(`Could not resolve target ${target}`) + } + } + async goTo(target) { + const resolved = this.resolveNavigation(target) + try { + await this.renderer.goTo(resolved) + this.history.pushState(target) + return resolved + } catch(e) { + console.error(e) + console.error(`Could not go to ${target}`) + } + } + async goToFraction(frac) { + const [index, anchor] = this.#sectionProgress.getSection(frac) + await this.renderer.goTo({ index, anchor }) + this.history.pushState({ fraction: frac }) + } + async select(target) { + try { + const obj = await this.resolveNavigation(target) + await this.renderer.goTo({ ...obj, select: true }) + this.history.pushState(target) + } catch(e) { + console.error(e) + console.error(`Could not go to ${target}`) + } + } + deselect() { + for (const { doc } of this.renderer.getContents()) + doc.defaultView.getSelection().removeAllRanges() + } + getSectionFractions() { + return (this.#sectionProgress?.sectionFractions ?? []) + .map(x => x + Number.EPSILON) + } + getProgressOf(index, range) { + const tocItem = this.#tocProgress?.getProgress(index, range) + const pageItem = this.#pageProgress?.getProgress(index, range) + return { tocItem, pageItem } + } + async getTOCItemOf(target) { + try { + const { index, anchor } = await this.resolveNavigation(target) + const doc = await this.book.sections[index].createDocument() + const frag = anchor(doc) + const isRange = frag instanceof Range + const range = isRange ? frag : doc.createRange() + if (!isRange) range.selectNodeContents(frag) + return this.#tocProgress.getProgress(index, range) + } catch(e) { + console.error(e) + console.error(`Could not get ${target}`) + } + } + async prev(distance) { + await this.renderer.prev(distance) + } + async next(distance) { + await this.renderer.next(distance) + } + goLeft() { + return this.book.dir === 'rtl' ? this.next() : this.prev() + } + goRight() { + return this.book.dir === 'rtl' ? this.prev() : this.next() + } + async * #searchSection(matcher, query, index) { + const doc = await this.book.sections[index].createDocument() + for (const { range, excerpt } of matcher(doc, query)) + yield { cfi: this.getCFI(index, range), excerpt } + } + async * #searchBook(matcher, query) { + const { sections } = this.book + for (const [index, { createDocument }] of sections.entries()) { + if (!createDocument) continue + const doc = await createDocument() + const subitems = Array.from(matcher(doc, query), ({ range, excerpt }) => + ({ cfi: this.getCFI(index, range), excerpt })) + const progress = (index + 1) / sections.length + yield { progress } + if (subitems.length) yield { index, subitems } + } + } + async * search(opts) { + this.clearSearch() + this.#searchDraw = opts.draw ?? Overlayer.outline + this.#searchDrawOptions = opts.drawOptions + const { searchMatcher } = await import('./search.js') + const { query, index } = opts + const matcher = searchMatcher(textWalker, + { defaultLocale: this.language, ...opts }) + const iter = index != null + ? this.#searchSection(matcher, query, index) + : this.#searchBook(matcher, query) + + const list = [] + this.#searchResults.set(index, list) + + for await (const result of iter) { + if (result.subitems){ + const list = result.subitems + .map(({ cfi }) => ({ value: SEARCH_PREFIX + cfi })) + this.#searchResults.set(result.index, list) + for (const item of list) this.addAnnotation(item) + yield { + label: this.#tocProgress.getProgress(result.index)?.label ?? '', + subitems: result.subitems, + } + } + else { + if (result.cfi) { + const item = { value: SEARCH_PREFIX + result.cfi } + list.push(item) + this.addAnnotation(item) + } + yield result + } + } + yield 'done' + } + clearSearch() { + for (const list of this.#searchResults.values()) + for (const item of list) this.deleteAnnotation(item) + this.#searchResults.clear() + } + async initTTS(granularity = 'word', highlight) { + const doc = this.renderer.getContents()[0].doc + if (this.tts && this.tts.doc === doc) return + const { TTS } = await import('./tts.js') + this.tts = new TTS(doc, textWalker, highlight || (range => + this.renderer.scrollToAnchor(range, true)), granularity) + } + startMediaOverlay() { + const { index } = this.renderer.getContents()[0] + return this.mediaOverlay.start(index) + } +} + +customElements.define('foliate-view', View)