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}
+
+ {title}
+ {#if authors.length}{authorLine(authors)}{/if}
+
+ {/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)}
+
+ {/each}
+
+{/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 @@
+
+
+
+
+
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}
+
+
+
+
+
+
+
- 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}
+
+
+
+
+ {#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}
+
+
+
The library is still being catalogued. Check back soon.
+
+ {: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}
+
+
+ {#if data.owner}
+ {#if form?.message}
{form.message}
{/if}
+
+
+ notes
+
+ {#if freeNotes.length}
+
+ {#each freeNotes as n (n.id)}
+ -
+ {#if editing === n.id}
+
+ {:else}
+
{n.note}
+
+ {/if}
+
+ {/each}
+
+ {:else}
+ No notes yet.
+ {/if}
+
+
+
+ highlights
+ {#if highlights.length}
+
+ {#each highlights as h (h.id)}
+ -
+
{h.highlight}
+ {#if editing === h.id}
+
+ {:else if h.note}
+ {h.note}
+ {/if}
+
+
+ {/each}
+
+ {:else}
+
+ Select text in the reader to highlight it{readable ? '' : ' (once there is a file)'}.
+
+ {/if}
+
+
+
+
+ {#if data.files.length}
+
+ files
+
+ {#each data.files as f (f.id)}
+ -
+ {f.name}
+ {f.format.toUpperCase()} · {size(f.sizeBytes)}
+
+ {/each}
+
+
+ {/if}
+ {/if}
+
+
+
+
+
diff --git a/src/routes/trackers/books/[id=integer]/notes/+server.ts b/src/routes/trackers/books/[id=integer]/notes/+server.ts
new file mode 100644
index 0000000..ea80177
--- /dev/null
+++ b/src/routes/trackers/books/[id=integer]/notes/+server.ts
@@ -0,0 +1,14 @@
+// Owner-only: create a highlight or note from the reader.
+import { error, json } from '@sveltejs/kit';
+import { requireOwner } from '#lib/server/owner.js';
+import { createNote, notePayload } from '#lib/server/books/notes.js';
+import type { RequestHandler } from './$types';
+
+export const POST: RequestHandler = async (event) => {
+ requireOwner(event);
+ const parsed = notePayload.safeParse(await event.request.json().catch(() => null));
+ if (!parsed.success) error(400, 'Invalid note');
+ const note = await createNote(Number(event.params.id), parsed.data);
+ if (!note) error(404, 'Not Found');
+ return json(note, { status: 201 });
+};
diff --git a/src/routes/trackers/books/[id=integer]/notes/[noteId=integer]/+server.ts b/src/routes/trackers/books/[id=integer]/notes/[noteId=integer]/+server.ts
new file mode 100644
index 0000000..b3e2aa4
--- /dev/null
+++ b/src/routes/trackers/books/[id=integer]/notes/[noteId=integer]/+server.ts
@@ -0,0 +1,21 @@
+// Owner-only: edit or delete one highlight/note.
+import { error, json } from '@sveltejs/kit';
+import { requireOwner } from '#lib/server/owner.js';
+import { deleteNote, noteUpdate, updateNote } from '#lib/server/books/notes.js';
+import type { RequestHandler } from './$types';
+
+export const PATCH: RequestHandler = async (event) => {
+ requireOwner(event);
+ const parsed = noteUpdate.safeParse(await event.request.json().catch(() => null));
+ if (!parsed.success) error(400, 'Invalid note');
+ const note = await updateNote(Number(event.params.id), Number(event.params.noteId), parsed.data);
+ if (!note) error(404, 'Not Found');
+ return json(note);
+};
+
+export const DELETE: RequestHandler = async (event) => {
+ requireOwner(event);
+ const ok = await deleteNote(Number(event.params.id), Number(event.params.noteId));
+ if (!ok) error(404, 'Not Found');
+ return new Response(null, { status: 204 });
+};
diff --git a/src/routes/trackers/books/[id=integer]/progress/+server.ts b/src/routes/trackers/books/[id=integer]/progress/+server.ts
new file mode 100644
index 0000000..0abaa77
--- /dev/null
+++ b/src/routes/trackers/books/[id=integer]/progress/+server.ts
@@ -0,0 +1,21 @@
+// Owner-only: the reader saves its position here (debounced, and via sendBeacon on close).
+import { error, json } from '@sveltejs/kit';
+import { requireOwner } from '#lib/server/owner.js';
+import { parseProgress } from '#lib/books/progress.js';
+import { saveProgress } from '#lib/server/books/library.js';
+import type { RequestHandler } from './$types';
+
+export const POST: RequestHandler = async (event) => {
+ requireOwner(event);
+ let body: unknown;
+ try {
+ body = JSON.parse(await event.request.text());
+ } catch {
+ error(400, 'Invalid JSON');
+ }
+ const input = parseProgress(body);
+ if (!input) error(400, 'Invalid progress');
+ const result = await saveProgress(Number(event.params.id), input);
+ if (!result) error(404, 'Not Found');
+ return json(result, { headers: { 'cache-control': 'private, no-store' } });
+};
diff --git a/src/routes/trackers/books/[id=integer]/read/+page.server.ts b/src/routes/trackers/books/[id=integer]/read/+page.server.ts
new file mode 100644
index 0000000..4f20a83
--- /dev/null
+++ b/src/routes/trackers/books/[id=integer]/read/+page.server.ts
@@ -0,0 +1,52 @@
+// Owner-only in-browser reader. Everyone else gets the same 404 as a missing page.
+import { error } from '@sveltejs/kit';
+import { resolve } from '$app/paths';
+import { requireOwner } from '#lib/server/owner.js';
+import { getWork, workFiles, workNotes } from '#lib/server/books/library.js';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async (event) => {
+ requireOwner(event);
+ event.setHeaders({ 'cache-control': 'private, no-store' });
+ const id = Number(event.params.id);
+ const found = await getWork(id);
+ if (!found) error(404, 'Not Found');
+ const { work, progress } = found;
+
+ const files = (await workFiles(id)).filter((f) => f.readable);
+ const wanted = Number(event.url.searchParams.get('file'));
+ const file =
+ files.find((f) => f.id === wanted) ?? files.find((f) => f.id === progress?.fileId) ?? files[0];
+ if (!file) error(404, 'No readable file for this book yet');
+
+ // Resume where we left off, but only if the saved position belongs to this file.
+ const sameFile = progress?.fileId === file.id;
+ const notes = (await workNotes(id)).filter(
+ (n) => n.highlight && n.locator && (n.fileId === file.id || n.fileId === null)
+ );
+
+ return {
+ book: {
+ id: work.id,
+ title: work.title,
+ authors: work.authors,
+ pageCount: work.pageCount
+ },
+ file: {
+ id: file.id,
+ format: file.format,
+ engine: file.format === 'pdf' ? ('pdf' as const) : ('foliate' as const),
+ url: resolve('/trackers/books/files/[id=integer]', { id: String(file.id) }),
+ name: file.path.split('/').at(-1) ?? `book.${file.format}`
+ },
+ otherFiles: files.filter((f) => f.id !== file.id).map((f) => ({ id: f.id, format: f.format })),
+ resume: {
+ locator: sameFile ? (progress?.locator ?? null) : null,
+ percent: sameFile ? (progress?.percent ?? 0) : 0,
+ page: sameFile ? (progress?.page ?? null) : null
+ },
+ jumpTo: event.url.searchParams.get('at'),
+ highlights: notes,
+ seo: { title: `Reading ${work.title}`, description: work.title, index: false }
+ };
+};
diff --git a/src/routes/trackers/books/[id=integer]/read/+page.svelte b/src/routes/trackers/books/[id=integer]/read/+page.svelte
new file mode 100644
index 0000000..8787a9f
--- /dev/null
+++ b/src/routes/trackers/books/[id=integer]/read/+page.svelte
@@ -0,0 +1,999 @@
+
+
+
+
+
+
+
+
+
+ {#if isPdf}
+ {
+ toc = t;
+ ready = true;
+ }}
+ {onrelocate}
+ {onselection}
+ onhighlightclick={openHighlight}
+ {onkey}
+ onloading={(f) => (loading = f)}
+ onerror={(m) => (failure = m)}
+ />
+ {:else}
+ {
+ toc = t;
+ ready = true;
+ }}
+ {onrelocate}
+ {onselection}
+ onhighlightclick={openHighlight}
+ {onkey}
+ onloading={(f) => (loading = f)}
+ onerror={(m) => (failure = m)}
+ />
+ {#if ready && settings.flow === 'paginated'}
+
+
+ {/if}
+ {/if}
+
+ {#if failure}
+
+ {:else if !ready}
+
+
+ {loading === null || loading === 0
+ ? 'Opening…'
+ : `Downloading ${Math.round(loading * 100)}%`}
+
+
+
+ {/if}
+
+
+ {#if panel}
+
+ {/if}
+
+
+ {#if selection || active}
+
+ {#if active}
+
{active.highlight}
+
+ {#each highlightColors as c (c)}
+
+ {/each}
+
+
+
+
+
+ {:else if selection}
+
+ {#each highlightColors as c (c)}
+
+ {/each}
+
+
+
+
+ {#if noteFor === 'selection'}
+
+
+ {/if}
+ {/if}
+
+ {/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)