From bc99dc593933f81bd5d9ddae7a1ee24ea8bbf814 Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 07:09:59 -0500 Subject: [PATCH 001/191] feat(rust): scaffold Rust rewrite workspace + redesigned schema Foundation milestone for the Play/Scala 3 -> Rust rewrite (plan: clean-slate, full parity, Axum + SQLx + Askama, JSONB document columns, noodles for genomics, Apple `container` PostGIS for Docker-less local testing). - 8-crate Cargo workspace (du-domain/db/bio/atproto/external/web/jobs/migrate); compiles and tests green. du-web boots Axum with the /health endpoint. - du-domain: typed IDs, Postgres-mirroring enums, and the variant JSONB contract (Coordinates/Aliases/Annotations) with round-trip tests. - Redesigned schema (migrations 0001-0009) across 10 namespaces, verified applying to live PostGIS. De-sprawl: 3 biosample tables -> 1 unified core.biosample; ~7 deprecated child tables folded into JSONB on parents; metadata DB collapsed into `fed`; scattered at_uri/at_cid -> one `atproto` JSONB column; GIN/GiST/expression indexes on queried JSONB paths. - du-db: PgPool + run_migrations; live-DB integration test (gated on DATABASE_URL) covering all schemas + JSONB variant round-trip. build.rs watches migrations/ so sqlx::migrate! re-embeds on change. - scripts/test-db.sh: Apple `container` PostGIS harness (IP-aware, since Apple containers have no localhost port forwarding), native DATABASE_URL fallback. - Multi-stage Dockerfile (slim runtime, single binary, no JRE), compose.yaml, .env.example. Coexists with the Scala app under rust/ during the transition. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/.env.example | 17 + rust/.gitignore | 3 + rust/Cargo.lock | 3101 +++++++++++++++++ rust/Cargo.toml | 77 + rust/Dockerfile | 44 + rust/compose.yaml | 43 + rust/crates/du-atproto/Cargo.toml | 14 + rust/crates/du-atproto/src/lib.rs | 5 + rust/crates/du-bio/Cargo.toml | 12 + rust/crates/du-bio/src/lib.rs | 5 + rust/crates/du-db/Cargo.toml | 20 + rust/crates/du-db/build.rs | 7 + rust/crates/du-db/src/lib.rs | 33 + rust/crates/du-db/tests/migrations.rs | 125 + rust/crates/du-domain/Cargo.toml | 15 + rust/crates/du-domain/src/enums.rs | 143 + rust/crates/du-domain/src/error.rs | 15 + rust/crates/du-domain/src/ids.rs | 54 + rust/crates/du-domain/src/lib.rs | 15 + rust/crates/du-domain/src/variant.rs | 109 + rust/crates/du-external/Cargo.toml | 14 + rust/crates/du-external/src/lib.rs | 4 + rust/crates/du-jobs/Cargo.toml | 19 + rust/crates/du-jobs/src/main.rs | 14 + rust/crates/du-migrate/Cargo.toml | 23 + rust/crates/du-migrate/src/main.rs | 17 + rust/crates/du-web/Cargo.toml | 25 + rust/crates/du-web/src/main.rs | 36 + rust/crates/du-web/src/routes.rs | 27 + rust/migrations/0001_foundation.sql | 33 + rust/migrations/0002_core.sql | 80 + rust/migrations/0003_tree.sql | 214 ++ rust/migrations/0004_genomics.sql | 220 ++ rust/migrations/0005_ident.sql | 121 + rust/migrations/0006_pubs.sql | 81 + rust/migrations/0007_ibd.sql | 126 + rust/migrations/0008_fed.sql | 75 + .../0009_social_support_billing.sql | 130 + rust/scripts/test-db.sh | 130 + 39 files changed, 5246 insertions(+) create mode 100644 rust/.env.example create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/Dockerfile create mode 100644 rust/compose.yaml create mode 100644 rust/crates/du-atproto/Cargo.toml create mode 100644 rust/crates/du-atproto/src/lib.rs create mode 100644 rust/crates/du-bio/Cargo.toml create mode 100644 rust/crates/du-bio/src/lib.rs create mode 100644 rust/crates/du-db/Cargo.toml create mode 100644 rust/crates/du-db/build.rs create mode 100644 rust/crates/du-db/src/lib.rs create mode 100644 rust/crates/du-db/tests/migrations.rs create mode 100644 rust/crates/du-domain/Cargo.toml create mode 100644 rust/crates/du-domain/src/enums.rs create mode 100644 rust/crates/du-domain/src/error.rs create mode 100644 rust/crates/du-domain/src/ids.rs create mode 100644 rust/crates/du-domain/src/lib.rs create mode 100644 rust/crates/du-domain/src/variant.rs create mode 100644 rust/crates/du-external/Cargo.toml create mode 100644 rust/crates/du-external/src/lib.rs create mode 100644 rust/crates/du-jobs/Cargo.toml create mode 100644 rust/crates/du-jobs/src/main.rs create mode 100644 rust/crates/du-migrate/Cargo.toml create mode 100644 rust/crates/du-migrate/src/main.rs create mode 100644 rust/crates/du-web/Cargo.toml create mode 100644 rust/crates/du-web/src/main.rs create mode 100644 rust/crates/du-web/src/routes.rs create mode 100644 rust/migrations/0001_foundation.sql create mode 100644 rust/migrations/0002_core.sql create mode 100644 rust/migrations/0003_tree.sql create mode 100644 rust/migrations/0004_genomics.sql create mode 100644 rust/migrations/0005_ident.sql create mode 100644 rust/migrations/0006_pubs.sql create mode 100644 rust/migrations/0007_ibd.sql create mode 100644 rust/migrations/0008_fed.sql create mode 100644 rust/migrations/0009_social_support_billing.sql create mode 100755 rust/scripts/test-db.sh diff --git a/rust/.env.example b/rust/.env.example new file mode 100644 index 0000000..ef42429 --- /dev/null +++ b/rust/.env.example @@ -0,0 +1,17 @@ +# Copy to rust/.env and adjust. Loaded by dev tooling; never commit the real .env. + +# ── Database ────────────────────────────────────────────────────────────────── +# Local dev/test default matches scripts/test-db.sh (Apple `container` Postgres). +DATABASE_URL=postgres://postgres:dev@localhost:5432/decodingus?sslmode=disable + +# ── Web ─────────────────────────────────────────────────────────────────────── +PORT=9000 +RUST_LOG=info,du_web=debug +# Signing key for session cookies (generate a long random value in prod). +APP_SECRET=changeme + +# ── External integrations (filled in as subsystems are ported) ───────────────── +# OPENALEX_BASE_URL=https://api.openalex.org +# ENA_BASE_URL=https://www.ebi.ac.uk/ena/portal/api +# AWS_REGION=us-east-1 +# RECAPTCHA_ENABLED=false diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..e1252c3 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,3 @@ +/target +.env +# SQLx offline query cache is committed once a dev DB exists; ignore until then. diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..b9a4033 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,3101 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "du-atproto" +version = "0.1.0" +dependencies = [ + "du-domain", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "du-bio" +version = "0.1.0" +dependencies = [ + "du-domain", + "thiserror", +] + +[[package]] +name = "du-db" +version = "0.1.0" +dependencies = [ + "chrono", + "du-domain", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "du-domain" +version = "0.1.0" +dependencies = [ + "chrono", + "rust_decimal", + "serde", + "serde_json", + "thiserror", + "uuid", +] + +[[package]] +name = "du-external" +version = "0.1.0" +dependencies = [ + "du-domain", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "du-jobs" +version = "0.1.0" +dependencies = [ + "anyhow", + "du-db", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "du-migrate" +version = "0.1.0" +dependencies = [ + "anyhow", + "du-db", + "du-domain", + "serde", + "serde_json", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "du-web" +version = "0.1.0" +dependencies = [ + "anyhow", + "askama", + "axum", + "du-db", + "du-domain", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust_decimal" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..7612440 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,77 @@ +# DecodingUs — Rust workspace (rewrite of the Play/Scala 3 app) +# See /Users/jkane/.claude/plans/robust-knitting-lampson.md +[workspace] +resolver = "2" +members = [ + "crates/du-domain", + "crates/du-db", + "crates/du-bio", + "crates/du-atproto", + "crates/du-external", + "crates/du-web", + "crates/du-jobs", + "crates/du-migrate", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.80" +license = "BSD-3-Clause" +repository = "https://github.com/decodingus/decodingus" + +[workspace.dependencies] +# Internal crates +du-domain = { path = "crates/du-domain" } +du-db = { path = "crates/du-db" } +du-bio = { path = "crates/du-bio" } +du-atproto = { path = "crates/du-atproto" } +du-external = { path = "crates/du-external" } + +# Async runtime + web +tokio = { version = "1", features = ["full"] } +axum = { version = "0.7", features = ["macros"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "catch-panic"] } +tower-cookies = "0.10" + +# Database (runtime-checked queries for now; switch to compile-time macros + .sqlx +# offline cache once a dev DB is reachable — see plan §3 / §9) +sqlx = { version = "0.8", default-features = false, features = [ + "runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate", +] } + +# Templating (typed, compile-time — Twirl analog) +askama = "0.12" + +# Serialization / JSONB payloads +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Common types +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +rust_decimal = "1" + +# HTTP client (external APIs) +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# Errors / logging / config +thiserror = "2" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Crypto +argon2 = "0.5" +bcrypt = "0.16" +ed25519-dalek = "2" +aes-gcm = "0.10" + +# Genomics (pure Rust — replaces htsjdk) +noodles = { version = "0.85", features = ["vcf", "gff", "fasta", "bed"] } + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/rust/Dockerfile b/rust/Dockerfile new file mode 100644 index 0000000..75d9f58 --- /dev/null +++ b/rust/Dockerfile @@ -0,0 +1,44 @@ +# DecodingUs (Rust) — multi-stage build to a single static-ish binary. +# No JRE, no htslib system lib (genomics is pure-Rust via noodles). +# +# docker build -t decodingus -f rust/Dockerfile rust +# (or via compose.yaml) + +# ── builder ────────────────────────────────────────────────────────────────── +FROM rust:1-bookworm AS builder +WORKDIR /build + +# Cache dependencies: copy manifests first, then sources. +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY migrations ./migrations +# SQLx is built in offline mode in CI/Docker (no DB at build time). Once a dev DB +# exists, commit the `.sqlx/` cache and this picks it up automatically. +ENV SQLX_OFFLINE=true +RUN cargo build --release --bin decodingus --bin decodingus-jobs --bin decodingus-migrate + +# ── runtime ────────────────────────────────────────────────────────────────── +FROM debian:bookworm-slim AS runtime +# curl for the healthcheck; ca-certificates for outbound TLS (OpenAlex/ENA/AWS). +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -r decodingus && useradd -r -g decodingus decodingus +WORKDIR /app + +COPY --from=builder /build/target/release/decodingus /usr/local/bin/decodingus +COPY --from=builder /build/target/release/decodingus-jobs /usr/local/bin/decodingus-jobs +COPY --from=builder /build/target/release/decodingus-migrate /usr/local/bin/decodingus-migrate +# Static assets + migrations shipped alongside the binary. +COPY --chown=decodingus:decodingus assets ./assets +COPY --chown=decodingus:decodingus migrations ./migrations + +USER decodingus +EXPOSE 9000 +ENV PORT=9000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost:9000/health || exit 1 + +CMD ["decodingus"] diff --git a/rust/compose.yaml b/rust/compose.yaml new file mode 100644 index 0000000..d66ba57 --- /dev/null +++ b/rust/compose.yaml @@ -0,0 +1,43 @@ +# DecodingUs (Rust) — production-ish compose. Works with Docker or Apple +# `container compose`. Mirrors the deployment intent of the legacy compose. +# +# docker compose up --build (or: container compose up --build) + +services: + db: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_PASSWORD: ${DU_PG_PASSWORD:-dev} + POSTGRES_DB: ${DU_PG_DB:-decodingus} + ports: + - "5432:5432" + volumes: + - du-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d ${DU_PG_DB:-decodingus}"] + interval: 10s + timeout: 5s + retries: 10 + + app: + build: + context: . + dockerfile: Dockerfile + environment: + DATABASE_URL: postgres://postgres:${DU_PG_PASSWORD:-dev}@db:5432/${DU_PG_DB:-decodingus}?sslmode=disable + APP_SECRET: ${APP_SECRET:-changeme} + RUST_LOG: ${RUST_LOG:-info,du_web=debug} + ports: + - "9000:9000" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:9000/health"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + du-pgdata: diff --git a/rust/crates/du-atproto/Cargo.toml b/rust/crates/du-atproto/Cargo.toml new file mode 100644 index 0000000..16757f3 --- /dev/null +++ b/rust/crates/du-atproto/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "du-atproto" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +# AT Protocol: DID/handle resolution, Ed25519 signature verification, firehose +# decode, PDS client/fleet. Crypto + HTTP deps added during implementation. +[dependencies] +du-domain = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/rust/crates/du-atproto/src/lib.rs b/rust/crates/du-atproto/src/lib.rs new file mode 100644 index 0000000..0c3939b --- /dev/null +++ b/rust/crates/du-atproto/src/lib.rs @@ -0,0 +1,5 @@ +//! AT Protocol / PDS federation (plan §7). Scaffold only. +//! +//! Planned: `did` (PLC directory + handle resolution), `signature` (Ed25519 +//! multibase verification against AT Protocol spec test vectors), `firehose` +//! (Atmosphere lexicon event decode), `pds` (client + fleet heartbeat/submission). diff --git a/rust/crates/du-bio/Cargo.toml b/rust/crates/du-bio/Cargo.toml new file mode 100644 index 0000000..19a5fb6 --- /dev/null +++ b/rust/crates/du-bio/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "du-bio" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +# Genomics file I/O (pure Rust, replaces htsjdk). noodles + a ported liftover +# module are added when the genomics ingestion subsystem is implemented. +[dependencies] +du-domain = { workspace = true } +thiserror = { workspace = true } diff --git a/rust/crates/du-bio/src/lib.rs b/rust/crates/du-bio/src/lib.rs new file mode 100644 index 0000000..584dd43 --- /dev/null +++ b/rust/crates/du-bio/src/lib.rs @@ -0,0 +1,5 @@ +//! Genomics file I/O for DecodingUs — pure Rust, replacing the JVM `htsjdk`. +//! +//! Planned modules (plan §6): `vcf`/`gff`/`fasta` (noodles wrappers), `liftover` +//! (ported UCSC chain-file interval mapping — no drop-in crate exists), and +//! `callable_loci` (BED interval math). Scaffold only for now. diff --git a/rust/crates/du-db/Cargo.toml b/rust/crates/du-db/Cargo.toml new file mode 100644 index 0000000..dc9b31f --- /dev/null +++ b/rust/crates/du-db/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "du-db" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +# Data-access layer: SQLx pool + per-aggregate query modules. Runtime-checked +# queries for now (no live DB in this environment); migrate to compile-time +# `query_as!` + committed `.sqlx` offline cache once a dev DB is reachable. +[dependencies] +du-domain = { workspace = true } +sqlx = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } diff --git a/rust/crates/du-db/build.rs b/rust/crates/du-db/build.rs new file mode 100644 index 0000000..9271537 --- /dev/null +++ b/rust/crates/du-db/build.rs @@ -0,0 +1,7 @@ +// `sqlx::migrate!` embeds the migrations directory at COMPILE time. Without this +// hint, adding/editing a .sql file does not rebuild du-db, so the embedded set +// goes stale and migrations silently fail to apply. Watch the directory so any +// change forces a recompile. +fn main() { + println!("cargo:rerun-if-changed=../../migrations"); +} diff --git a/rust/crates/du-db/src/lib.rs b/rust/crates/du-db/src/lib.rs new file mode 100644 index 0000000..f242e66 --- /dev/null +++ b/rust/crates/du-db/src/lib.rs @@ -0,0 +1,33 @@ +//! Data-access layer. Owns the `PgPool` and exposes per-aggregate query modules. +//! +//! Status: scaffold. The pool + error type are wired; query modules +//! (`biosample`, `variant`, `haplogroup`, …) land as each subsystem is ported. + +use sqlx::postgres::{PgPool, PgPoolOptions}; +use std::time::Duration; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DbError { + #[error("database error: {0}")] + Sqlx(#[from] sqlx::Error), + #[error("migration error: {0}")] + Migrate(#[from] sqlx::migrate::MigrateError), +} + +/// Connect and return a pool. `database_url` is the standard `postgres://` DSN +/// (driven by `DATABASE_URL`; see `scripts/test-db.sh` and plan §9). +pub async fn connect(database_url: &str, max_connections: u32) -> Result { + let pool = PgPoolOptions::new() + .max_connections(max_connections) + .acquire_timeout(Duration::from_secs(10)) + .connect(database_url) + .await?; + Ok(pool) +} + +/// Apply the workspace migrations (the redesigned schema) to the given pool. +pub async fn run_migrations(pool: &PgPool) -> Result<(), DbError> { + sqlx::migrate!("../../migrations").run(pool).await?; + Ok(()) +} diff --git a/rust/crates/du-db/tests/migrations.rs b/rust/crates/du-db/tests/migrations.rs new file mode 100644 index 0000000..669619e --- /dev/null +++ b/rust/crates/du-db/tests/migrations.rs @@ -0,0 +1,125 @@ +//! Integration test: applies the redesigned schema to a live Postgres and +//! exercises the JSONB variant contract end-to-end. +//! +//! Skips (passes) when DATABASE_URL is unset so `cargo test` stays green without +//! a database. To run for real: +//! eval "$(./scripts/test-db.sh up)" && cargo test -p du-db -- --nocapture + +use du_domain::variant::{BuildCoordinate, Coordinates}; +use du_domain::ReferenceBuild; + +fn database_url() -> Option { + std::env::var("DATABASE_URL").ok().filter(|s| !s.is_empty()) +} + +#[tokio::test] +async fn migrations_apply_and_variant_jsonb_roundtrips() { + let Some(url) = database_url() else { + eprintln!("DATABASE_URL unset — skipping live-DB test"); + return; + }; + + let pool = du_db::connect(&url, 4).await.expect("connect"); + du_db::run_migrations(&pool).await.expect("run migrations"); + + // A representative table from every schema in the redesign exists — proves + // the full migration sequence (0001..0009) applies as a unit. + for obj in [ + "core.variant", + "core.biosample", + "core.specimen_donor", + "core.genome_region", + "tree.haplogroup", + "tree.change_set", + "tree.biosample_private_variant", + "genomics.sequence_file", + "genomics.alignment_metadata", + "genomics.test_type_definition", + "pubs.publication", + "pubs.publication_biosample", + "ident.users", + "ident.roles", + "ibd.ibd_discovery_index", + "ibd.population_breakdown", + "fed.pds_node", + "fed.pds_submission", + "social.reputation_event", + "social.group_project", + "support.contact_message", + "billing.patron_subscription", + ] { + let exists: Option = sqlx::query_scalar("SELECT to_regclass($1)::text") + .bind(obj) + .fetch_one(&pool) + .await + .expect("to_regclass"); + assert_eq!(exists.as_deref(), Some(obj), "{obj} should exist"); + } + + // The base RBAC roles are seeded. + let roles: i64 = + sqlx::query_scalar("SELECT count(*) FROM ident.roles WHERE name IN ('Admin','Curator','TreeCurator')") + .fetch_one(&pool) + .await + .expect("count roles"); + assert_eq!(roles, 3, "base roles should be seeded"); + + // PostGIS / citext extensions are installed. + let postgis: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_extension WHERE extname IN ('postgis','citext','pgcrypto')", + ) + .fetch_one(&pool) + .await + .expect("pg_extension"); + assert_eq!(postgis, 3, "postgis, citext, pgcrypto should be installed"); + + // Insert a variant whose coordinates JSONB is the du-domain shape, then read + // it back and confirm it deserializes and is queryable by JSONB path. + let mut coords = Coordinates::default(); + coords.set( + ReferenceBuild::GRCh38, + BuildCoordinate { + contig: "chrY".into(), + position: 2_787_319, + reference_allele: Some("C".into()), + alternate_allele: Some("T".into()), + }, + ); + let coords_json = serde_json::to_value(&coords).unwrap(); + + let id: i64 = sqlx::query_scalar( + "INSERT INTO core.variant (canonical_name, mutation_type, coordinates) + VALUES ($1, 'SNP', $2) RETURNING id", + ) + .bind("M269") + .bind(&coords_json) + .fetch_one(&pool) + .await + .expect("insert variant"); + + // Read the JSONB back as a serde_json::Value and decode into the typed shape. + let stored: serde_json::Value = + sqlx::query_scalar("SELECT coordinates FROM core.variant WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + .expect("select coordinates"); + let decoded: Coordinates = serde_json::from_value(stored).unwrap(); + assert_eq!(decoded.get(ReferenceBuild::GRCh38).unwrap().position, 2_787_319); + + // JSONB-path query (the kind the GIN index accelerates) finds it by build. + let by_path: i64 = sqlx::query_scalar( + "SELECT count(*) FROM core.variant WHERE coordinates -> 'GRCh38' ->> 'contig' = 'chrY'", + ) + .fetch_one(&pool) + .await + .expect("jsonb path query"); + assert!(by_path >= 1, "variant should be found by JSONB path"); + + // Clean up so the test is re-runnable against a persistent DB. + sqlx::query("DELETE FROM core.variant WHERE id = $1") + .bind(id) + .execute(&pool) + .await + .expect("cleanup"); +} diff --git a/rust/crates/du-domain/Cargo.toml b/rust/crates/du-domain/Cargo.toml new file mode 100644 index 0000000..6656d62 --- /dev/null +++ b/rust/crates/du-domain/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "du-domain" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +# Pure domain layer: types + algorithms, NO IO. Keep dependencies minimal. +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +rust_decimal = { workspace = true } +thiserror = { workspace = true } diff --git a/rust/crates/du-domain/src/enums.rs b/rust/crates/du-domain/src/enums.rs new file mode 100644 index 0000000..a53ab8d --- /dev/null +++ b/rust/crates/du-domain/src/enums.rs @@ -0,0 +1,143 @@ +//! Domain enums mirroring the Postgres native enums in the redesigned schema. +//! +//! `serde(rename_all = "SCREAMING_SNAKE_CASE")` keeps the wire/JSONB form equal +//! to the Postgres enum labels so `du-db` can map these directly with +//! `#[derive(sqlx::Type)]` and JSONB round-trips are stable. + +use serde::{Deserialize, Serialize}; + +/// Y-DNA vs mtDNA. (legacy `dna_type` / `HaplogroupType`) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DnaType { + YDna, + MtDna, +} + +/// Origin of a biosample. Replaces the three separate legacy tables +/// (`biosample`, `citizen_biosample`, `pgp_biosample`) — see plan §2. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum BiosampleSource { + Standard, + Citizen, + Pgp, + External, + Ancient, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum BiologicalSex { + Male, + Female, + Intersex, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DataGenerationMethod { + Sequencing, + Genotyping, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum TargetType { + WholeGenome, + YChromosome, + MtDna, + Autosomal, + XChromosome, + Mixed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MutationType { + Snp, + Indel, + Str, + Del, + Ins, + Mnp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NamingStatus { + Unnamed, + PendingReview, + Named, +} + +/// Reference genome builds tracked across the platform. The redesigned variant +/// `coordinates` JSONB is keyed by these. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ReferenceBuild { + #[serde(rename = "GRCh37")] + GRCh37, + #[serde(rename = "GRCh38")] + GRCh38, + /// T2T-CHM13 / hs1. + #[serde(rename = "hs1")] + Hs1, +} + +impl ReferenceBuild { + pub fn as_str(&self) -> &'static str { + match self { + ReferenceBuild::GRCh37 => "GRCh37", + ReferenceBuild::GRCh38 => "GRCh38", + ReferenceBuild::Hs1 => "hs1", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "GRCh37" => Ok(ReferenceBuild::GRCh37), + "GRCh38" => Ok(ReferenceBuild::GRCh38), + "hs1" | "CHM13" | "T2T-CHM13" => Ok(ReferenceBuild::Hs1), + other => Err(crate::DomainError::InvalidBuild(other.to_string())), + } + } +} + +/// Tree change-set lifecycle (legacy `tree.change_set_status`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ChangeSetStatus { + Draft, + ReadyForReview, + UnderReview, + Applied, + Discarded, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reference_build_roundtrips_through_json() { + for b in [ReferenceBuild::GRCh37, ReferenceBuild::GRCh38, ReferenceBuild::Hs1] { + let s = serde_json::to_string(&b).unwrap(); + let back: ReferenceBuild = serde_json::from_str(&s).unwrap(); + assert_eq!(b, back); + assert_eq!(format!("\"{}\"", b.as_str()), s); + } + } + + #[test] + fn enum_labels_match_screaming_snake_case() { + assert_eq!(serde_json::to_string(&DnaType::YDna).unwrap(), "\"Y_DNA\""); + assert_eq!( + serde_json::to_string(&BiosampleSource::External).unwrap(), + "\"EXTERNAL\"" + ); + assert_eq!( + serde_json::to_string(&NamingStatus::PendingReview).unwrap(), + "\"PENDING_REVIEW\"" + ); + } +} diff --git a/rust/crates/du-domain/src/error.rs b/rust/crates/du-domain/src/error.rs new file mode 100644 index 0000000..76b6dfa --- /dev/null +++ b/rust/crates/du-domain/src/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +/// Errors raised by pure domain logic (validation, invariant violations, +/// algorithm preconditions). IO/database errors live in their own crates. +#[derive(Debug, Error)] +pub enum DomainError { + #[error("validation failed: {0}")] + Validation(String), + + #[error("invalid reference build: {0}")] + InvalidBuild(String), + + #[error("invariant violated: {0}")] + Invariant(String), +} diff --git a/rust/crates/du-domain/src/ids.rs b/rust/crates/du-domain/src/ids.rs new file mode 100644 index 0000000..5cfbbf3 --- /dev/null +++ b/rust/crates/du-domain/src/ids.rs @@ -0,0 +1,54 @@ +//! Strongly-typed identifiers. Prevents mixing up the many integer/UUID keys +//! that flow through the genomics domain (a frequent source of bugs in the +//! legacy code where everything was a bare `Int`/`UUID`). + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +macro_rules! int_id { + ($(#[$m:meta])* $name:ident) => { + $(#[$m])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub i64); + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + impl From for $name { + fn from(v: i64) -> Self { $name(v) } + } + }; +} + +int_id!(/// Primary key of `core.variant`. + VariantId); +int_id!(/// Primary key of `tree.haplogroup`. + HaplogroupId); +int_id!(/// Primary key of `pub.publication`. + PublicationId); + +/// A biosample's stable cross-system identity (UUID), shared by all sources +/// (standard/citizen/pgp/external/ancient) in the unified `core.biosample`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SampleGuid(pub Uuid); + +impl std::fmt::Display for SampleGuid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A user's stable identity (UUID). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct UserId(pub Uuid); + +impl std::fmt::Display for UserId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/rust/crates/du-domain/src/lib.rs b/rust/crates/du-domain/src/lib.rs new file mode 100644 index 0000000..3d4d61f --- /dev/null +++ b/rust/crates/du-domain/src/lib.rs @@ -0,0 +1,15 @@ +//! Pure domain layer for DecodingUs: types and algorithms with no IO. +//! +//! This crate intentionally has no database, web, or async dependencies. JSONB +//! payload shapes (the redesigned "document columns") live here as `serde` +//! structs so both `du-db` (persistence) and `du-web` (presentation) share one +//! source of truth. + +pub mod enums; +pub mod error; +pub mod ids; +pub mod variant; + +pub use enums::*; +pub use error::DomainError; +pub use ids::*; diff --git a/rust/crates/du-domain/src/variant.rs b/rust/crates/du-domain/src/variant.rs new file mode 100644 index 0000000..8d8dbd7 --- /dev/null +++ b/rust/crates/du-domain/src/variant.rs @@ -0,0 +1,109 @@ +//! Variant domain model and its JSONB payload shapes. +//! +//! This is the canonical example of the schema redesign: the legacy `variant` + +//! `variant_alias` tables (and per-build coordinate rows) collapse into one +//! `core.variant` row whose `aliases`, `coordinates`, and `annotations` columns +//! are JSONB. These structs ARE that JSONB contract. + +use crate::enums::{MutationType, NamingStatus, ReferenceBuild}; +use crate::ids::VariantId; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// A coordinate of a variant on one reference build. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildCoordinate { + /// Contig/accession on this build (e.g. "chrY", "CM000686.2"). + pub contig: String, + pub position: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_allele: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alternate_allele: Option, +} + +/// `core.variant.coordinates` JSONB — keyed by reference build. +/// Stored as `{ "GRCh38": {...}, "hs1": {...} }`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Coordinates(pub BTreeMap); + +impl Coordinates { + pub fn get(&self, build: ReferenceBuild) -> Option<&BuildCoordinate> { + self.0.get(build.as_str()) + } + + pub fn set(&mut self, build: ReferenceBuild, coord: BuildCoordinate) { + self.0.insert(build.as_str().to_string(), coord); + } +} + +/// `core.variant.aliases` JSONB — consolidates the old `variant_alias` rows. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Aliases { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub common_names: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rs_ids: Vec, + /// alias -> source attribution (e.g. "M269" -> "ISOGG"). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub sources: BTreeMap, +} + +/// `core.variant.annotations` JSONB. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Annotations { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cytobands: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub str_overlaps: Vec, +} + +/// A fully-hydrated variant (scalar columns + decoded JSONB payloads). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Variant { + pub id: VariantId, + pub canonical_name: String, + pub mutation_type: MutationType, + pub naming_status: NamingStatus, + pub aliases: Aliases, + pub coordinates: Coordinates, + pub annotations: Annotations, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordinates_json_is_keyed_by_build_label() { + let mut c = Coordinates::default(); + c.set( + ReferenceBuild::GRCh38, + BuildCoordinate { + contig: "chrY".into(), + position: 2_787_319, + reference_allele: Some("C".into()), + alternate_allele: Some("T".into()), + }, + ); + let json = serde_json::to_value(&c).unwrap(); + assert!(json.get("GRCh38").is_some(), "keyed by build label: {json}"); + assert_eq!(json["GRCh38"]["position"], 2_787_319); + + // round-trips and is queryable by typed build. + let back: Coordinates = serde_json::from_value(json).unwrap(); + assert_eq!(back.get(ReferenceBuild::GRCh38).unwrap().contig, "chrY"); + assert!(back.get(ReferenceBuild::Hs1).is_none()); + } + + #[test] + fn empty_alias_fields_are_omitted_from_json() { + let a = Aliases { + common_names: vec!["M269".into()], + ..Default::default() + }; + let json = serde_json::to_string(&a).unwrap(); + assert_eq!(json, r#"{"common_names":["M269"]}"#); + } +} diff --git a/rust/crates/du-external/Cargo.toml b/rust/crates/du-external/Cargo.toml new file mode 100644 index 0000000..be829d3 --- /dev/null +++ b/rust/crates/du-external/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "du-external" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +# External integrations: OpenAlex + ENA (reqwest), AWS SES + Secrets Manager, +# reCAPTCHA. HTTP + AWS SDK deps added during implementation. +[dependencies] +du-domain = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/rust/crates/du-external/src/lib.rs b/rust/crates/du-external/src/lib.rs new file mode 100644 index 0000000..b97cd6b --- /dev/null +++ b/rust/crates/du-external/src/lib.rs @@ -0,0 +1,4 @@ +//! External service clients (plan §7). Scaffold only. +//! +//! Planned: `openalex`, `ena`, `ses` (AWS SES email), `secrets` (AWS Secrets +//! Manager with a 1h TTL cache), `recaptcha` (with a mock impl behind a config flag). diff --git a/rust/crates/du-jobs/Cargo.toml b/rust/crates/du-jobs/Cargo.toml new file mode 100644 index 0000000..5154936 --- /dev/null +++ b/rust/crates/du-jobs/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "du-jobs" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[[bin]] +name = "decodingus-jobs" +path = "src/main.rs" + +# Scheduled background workers (plan §7): publication update/discovery, YBrowse +# ingest, variant export, match discovery. Scheduler + job deps added on impl. +[dependencies] +du-db = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } diff --git a/rust/crates/du-jobs/src/main.rs b/rust/crates/du-jobs/src/main.rs new file mode 100644 index 0000000..5871525 --- /dev/null +++ b/rust/crates/du-jobs/src/main.rs @@ -0,0 +1,14 @@ +//! Background job runner (replaces Pekko Quartz scheduler). Scaffold only — +//! the five scheduled jobs are wired in during the genomics/external milestone. + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + tracing::info!("decodingus-jobs scaffold — no jobs scheduled yet"); + Ok(()) +} diff --git a/rust/crates/du-migrate/Cargo.toml b/rust/crates/du-migrate/Cargo.toml new file mode 100644 index 0000000..394f318 --- /dev/null +++ b/rust/crates/du-migrate/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "du-migrate" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[[bin]] +name = "decodingus-migrate" +path = "src/main.rs" + +# One-time ETL: legacy Postgres -> redesigned schema (plan §8). Idempotent, +# resumable, with a `--verify` reconciliation pass. +[dependencies] +du-domain = { workspace = true } +du-db = { workspace = true } +sqlx = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } diff --git a/rust/crates/du-migrate/src/main.rs b/rust/crates/du-migrate/src/main.rs new file mode 100644 index 0000000..c37a475 --- /dev/null +++ b/rust/crates/du-migrate/src/main.rs @@ -0,0 +1,17 @@ +//! Legacy -> new schema ETL binary (plan §8). Scaffold only. +//! +//! Usage (planned): +//! decodingus-migrate --legacy --target run the ETL +//! decodingus-migrate --legacy --target --verify reconcile counts + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + tracing::info!("decodingus-migrate scaffold — ETL transformers not yet implemented"); + Ok(()) +} diff --git a/rust/crates/du-web/Cargo.toml b/rust/crates/du-web/Cargo.toml new file mode 100644 index 0000000..8184fc9 --- /dev/null +++ b/rust/crates/du-web/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "du-web" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[[bin]] +name = "decodingus" +path = "src/main.rs" + +# Axum web app: routers, extractors, Askama templates, i18n, HTMX fragments. +[dependencies] +du-domain = { workspace = true } +du-db = { workspace = true } +axum = { workspace = true } +tokio = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +askama = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } diff --git a/rust/crates/du-web/src/main.rs b/rust/crates/du-web/src/main.rs new file mode 100644 index 0000000..f827a2e --- /dev/null +++ b/rust/crates/du-web/src/main.rs @@ -0,0 +1,36 @@ +//! DecodingUs web binary (Axum). HTML + JSON API + static assets + firehose. +//! +//! Scaffold: boots tracing, builds the router, serves `/health` (carried over +//! from the Play app's load-balancer check). Subsystem routers are mounted as +//! they are ported (plan §4, §10). + +use axum::{routing::get, Router}; +use std::net::SocketAddr; + +mod routes; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,du_web=debug".into()), + ) + .init(); + + let app = build_router(); + + let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(9000); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!(%addr, "decodingus web starting"); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +/// Builds the application router. Split out so handler tests can exercise it +/// without binding a socket. +pub fn build_router() -> Router { + Router::new().route("/health", get(routes::health)) +} diff --git a/rust/crates/du-web/src/routes.rs b/rust/crates/du-web/src/routes.rs new file mode 100644 index 0000000..994a870 --- /dev/null +++ b/rust/crates/du-web/src/routes.rs @@ -0,0 +1,27 @@ +//! HTTP handlers. Grows into submodules (public, curator, api, federation) as +//! subsystems are ported. For now: the health check. + +use axum::http::StatusCode; + +/// Load-balancer / container health check. Mirrors the Play `/health` route. +pub async fn health() -> (StatusCode, &'static str) { + (StatusCode::OK, "ok") +} + +#[cfg(test)] +mod tests { + use crate::build_router; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; // for `oneshot` + + #[tokio::test] + async fn health_returns_ok() { + let app = build_router(); + let resp = app + .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } +} diff --git a/rust/migrations/0001_foundation.sql b/rust/migrations/0001_foundation.sql new file mode 100644 index 0000000..f07c2b6 --- /dev/null +++ b/rust/migrations/0001_foundation.sql @@ -0,0 +1,33 @@ +-- DecodingUs redesigned schema — foundation. +-- Namespaces, extensions, and native enums. See plan §2. +-- +-- The legacy app spread tables across public/auth/tree/social/support/genomics/ +-- billing + a second "metadata" database. We keep logical grouping via schemas +-- but in ONE database (the metadata DB collapses into `fed`). + +CREATE EXTENSION IF NOT EXISTS postgis; -- biosample/donor geometry(Point,4326) +CREATE EXTENSION IF NOT EXISTS citext; -- case-insensitive user email +CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid() + +CREATE SCHEMA IF NOT EXISTS core; -- samples, donors, variants, regions +CREATE SCHEMA IF NOT EXISTS tree; -- haplogroups + versioning +CREATE SCHEMA IF NOT EXISTS genomics; -- sequencing, coverage, callable loci +CREATE SCHEMA IF NOT EXISTS pubs; -- publications/studies (`pub` is reserved-ish; use pubs) +CREATE SCHEMA IF NOT EXISTS ident; -- users/auth/roles/atproto +CREATE SCHEMA IF NOT EXISTS ibd; -- match discovery +CREATE SCHEMA IF NOT EXISTS fed; -- PDS fleet/firehose (was the metadata DB) +CREATE SCHEMA IF NOT EXISTS social; -- reputation/messaging +CREATE SCHEMA IF NOT EXISTS support; -- contact messages +CREATE SCHEMA IF NOT EXISTS billing; -- subscriptions + +-- Native enums. Labels match du-domain serde forms (SCREAMING_SNAKE_CASE) so +-- Rust maps them directly via #[derive(sqlx::Type)] and JSONB round-trips align. +CREATE TYPE core.dna_type AS ENUM ('Y_DNA', 'MT_DNA'); +CREATE TYPE core.biological_sex AS ENUM ('MALE', 'FEMALE', 'INTERSEX'); +CREATE TYPE core.biosample_source AS ENUM ('STANDARD', 'CITIZEN', 'PGP', 'EXTERNAL', 'ANCIENT'); +CREATE TYPE core.data_generation_method AS ENUM ('SEQUENCING', 'GENOTYPING'); +CREATE TYPE core.target_type AS ENUM ('WHOLE_GENOME', 'Y_CHROMOSOME', 'MT_DNA', 'AUTOSOMAL', 'X_CHROMOSOME', 'MIXED'); +CREATE TYPE core.mutation_type AS ENUM ('SNP', 'INDEL', 'STR', 'DEL', 'INS', 'MNP'); +CREATE TYPE core.naming_status AS ENUM ('UNNAMED', 'PENDING_REVIEW', 'NAMED'); +CREATE TYPE tree.change_set_status AS ENUM ('DRAFT', 'READY_FOR_REVIEW', 'UNDER_REVIEW', 'APPLIED', 'DISCARDED'); +CREATE TYPE tree.tree_change_type AS ENUM ('CREATE', 'UPDATE', 'DELETE', 'REPARENT', 'VARIANT_EDIT'); diff --git a/rust/migrations/0002_core.sql b/rust/migrations/0002_core.sql new file mode 100644 index 0000000..470848d --- /dev/null +++ b/rust/migrations/0002_core.sql @@ -0,0 +1,80 @@ +-- core schema: variants, specimen donors, the UNIFIED biosample, genome regions. +-- This migration realizes the central de-sprawl moves from plan §2. + +-- ── Variants ────────────────────────────────────────────────────────────── +-- One row per variant. Replaces legacy `variant` + `variant_alias` + per-build +-- coordinate rows. JSONB payload shapes are defined in du-domain::variant. +CREATE TABLE core.variant ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + canonical_name TEXT NOT NULL, + mutation_type core.mutation_type NOT NULL, + naming_status core.naming_status NOT NULL DEFAULT 'UNNAMED', + aliases JSONB NOT NULL DEFAULT '{}'::jsonb, -- {common_names, rs_ids, sources} + coordinates JSONB NOT NULL DEFAULT '{}'::jsonb, -- {GRCh38:{...}, hs1:{...}, ...} + annotations JSONB NOT NULL DEFAULT '{}'::jsonb, -- {cytobands, str_overlaps} + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX variant_canonical_name_key ON core.variant (canonical_name); +-- GIN indexes on the queried JSONB paths (alias lookup, build-coordinate search). +CREATE INDEX variant_aliases_gin ON core.variant USING gin (aliases jsonb_path_ops); +CREATE INDEX variant_coordinates_gin ON core.variant USING gin (coordinates jsonb_path_ops); + +-- ── Specimen donors ─────────────────────────────────────────────────────── +-- Owns demographic data (consolidated from legacy biosample in evolutions 16/18). +CREATE TABLE core.specimen_donor ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + donor_identifier TEXT, + origin_biobank TEXT, + sex core.biological_sex, + donor_type core.biosample_source NOT NULL DEFAULT 'STANDARD', + geocoord geometry(Point, 4326), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX specimen_donor_geocoord_gist ON core.specimen_donor USING gist (geocoord); + +-- ── Unified biosample ─────────────────────────────────────────────────────── +-- Replaces three legacy tables (biosample, citizen_biosample, pgp_biosample). +-- `source` discriminates; `source_attrs` JSONB holds source-specific fields +-- (at_uri/at_cid, pgp_participant_id, ena accession, …). `atproto` is the single +-- consistent federation reference replacing scattered at_uri/at_cid columns. +CREATE TABLE core.biosample ( + sample_guid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + donor_id BIGINT REFERENCES core.specimen_donor(id), + source core.biosample_source NOT NULL, + accession TEXT, + alias TEXT, + description TEXT, + center_name TEXT, + locked BOOLEAN NOT NULL DEFAULT false, + deleted BOOLEAN NOT NULL DEFAULT false, + source_attrs JSONB NOT NULL DEFAULT '{}'::jsonb, + -- original haplogroup calls per publication (folded in from the dropped + -- biosample_original_haplogroup / citizen_* tables). + original_haplogroups JSONB NOT NULL DEFAULT '[]'::jsonb, + atproto JSONB, -- {uri, cid, repo_did} or NULL when not federated + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX biosample_accession_key ON core.biosample (accession) WHERE accession IS NOT NULL; +CREATE INDEX biosample_source_idx ON core.biosample (source); +CREATE INDEX biosample_source_attrs_gin ON core.biosample USING gin (source_attrs jsonb_path_ops); +-- Unique federation URI when present (citizen samples carry an at:// URI). +CREATE UNIQUE INDEX biosample_atproto_uri_key + ON core.biosample ((atproto->>'uri')) WHERE atproto IS NOT NULL; + +-- ── Genome regions ────────────────────────────────────────────────────────── +-- Multi-build structural regions (centromere/telomere/PAR/…). Coordinates as +-- JSONB keyed by build (legacy genome_region_v2 shape). +CREATE TABLE core.genome_region ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + region_type TEXT NOT NULL, + name TEXT NOT NULL, + coordinates JSONB NOT NULL DEFAULT '{}'::jsonb, -- {GRCh38:{contig,start,end}, hs1:{...}} + properties JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX genome_region_type_name_key ON core.genome_region (region_type, name); +CREATE INDEX genome_region_coordinates_gin ON core.genome_region USING gin (coordinates jsonb_path_ops); diff --git a/rust/migrations/0003_tree.sql b/rust/migrations/0003_tree.sql new file mode 100644 index 0000000..8be9542 --- /dev/null +++ b/rust/migrations/0003_tree.sql @@ -0,0 +1,214 @@ +-- tree schema: haplogroup phylogeny, temporal versioning, bulk-merge staging, +-- and the discovery pipeline. De-sprawl moves (plan §2): +-- * per-revision metadata tables (relationship_revision_metadata, +-- haplogroup_variant_metadata) fold into a `revision` JSONB column. +-- * discovery's polymorphic (sample_type, sample_id) collapses to one +-- core.biosample(sample_guid) FK. + +-- ── Phylogeny ──────────────────────────────────────────────────────────────── +CREATE TABLE tree.haplogroup ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name TEXT NOT NULL, + haplogroup_type core.dna_type NOT NULL, + lineage TEXT, + source TEXT, + confidence_level TEXT, + formed_ybp INTEGER, + tmrca_ybp INTEGER, + -- multi-source attribution + age-estimate detail, formerly scattered columns. + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), + valid_until TIMESTAMPTZ +); +CREATE UNIQUE INDEX haplogroup_name_type_key ON tree.haplogroup (name, haplogroup_type); +CREATE INDEX haplogroup_provenance_gin ON tree.haplogroup USING gin (provenance jsonb_path_ops); + +-- Temporal parent/child edges. `revision` JSONB folds the old +-- relationship_revision_metadata (author/timestamp/comment/change_type). +CREATE TABLE tree.haplogroup_relationship ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + child_haplogroup_id BIGINT NOT NULL REFERENCES tree.haplogroup(id), + parent_haplogroup_id BIGINT REFERENCES tree.haplogroup(id), + revision_id INTEGER NOT NULL DEFAULT 1, + source TEXT, + revision JSONB NOT NULL DEFAULT '{}'::jsonb, + valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), + valid_until TIMESTAMPTZ +); +CREATE INDEX haplogroup_rel_child_idx ON tree.haplogroup_relationship (child_haplogroup_id); +CREATE INDEX haplogroup_rel_parent_idx ON tree.haplogroup_relationship (parent_haplogroup_id); +-- One currently-valid parent per child. +CREATE UNIQUE INDEX haplogroup_rel_current_child_key + ON tree.haplogroup_relationship (child_haplogroup_id) WHERE valid_until IS NULL; + +-- Defining variants per haplogroup. `revision` JSONB folds haplogroup_variant_metadata. +CREATE TABLE tree.haplogroup_variant ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + haplogroup_id BIGINT NOT NULL REFERENCES tree.haplogroup(id), + variant_id BIGINT NOT NULL REFERENCES core.variant(id), + revision JSONB NOT NULL DEFAULT '{}'::jsonb, + valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), + valid_until TIMESTAMPTZ +); +CREATE UNIQUE INDEX haplogroup_variant_current_key + ON tree.haplogroup_variant (haplogroup_id, variant_id) WHERE valid_until IS NULL; + +-- Historical/archaeological date constraints for age estimation. +CREATE TABLE tree.genealogical_anchor ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + haplogroup_id BIGINT NOT NULL REFERENCES tree.haplogroup(id), + anchor_type TEXT NOT NULL, -- KNOWN_MRCA / MDKA / ANCIENT_DNA + date_ce INTEGER, + carbon_date_bp INTEGER, + confidence NUMERIC(4,3), + details JSONB NOT NULL DEFAULT '{}'::jsonb +); +CREATE INDEX genealogical_anchor_hg_idx ON tree.genealogical_anchor (haplogroup_id); + +-- Modal STR haplotypes for STR-based age estimation. +CREATE TABLE tree.haplogroup_ancestral_str ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + haplogroup_id BIGINT NOT NULL REFERENCES tree.haplogroup(id), + marker_name TEXT NOT NULL, + ancestral_value INTEGER NOT NULL, + confidence NUMERIC(4,3), + method TEXT -- MODAL / PHYLOGENETIC / MANUAL +); +CREATE INDEX haplogroup_ancestral_str_hg_idx ON tree.haplogroup_ancestral_str (haplogroup_id); + +-- ── Versioning / bulk merge staging ────────────────────────────────────────── +CREATE TABLE tree.change_set ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source TEXT NOT NULL, -- ISOGG, ytree.net, ... + haplogroup_type core.dna_type, + status tree.change_set_status NOT NULL DEFAULT 'DRAFT', + description TEXT, + change_count INTEGER NOT NULL DEFAULT 0, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + promoted_by TEXT, + promoted_at TIMESTAMPTZ +); +CREATE INDEX change_set_status_idx ON tree.change_set (status); + +CREATE TABLE tree.tree_change ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + change_set_id BIGINT NOT NULL REFERENCES tree.change_set(id) ON DELETE CASCADE, + change_type tree.tree_change_type NOT NULL, + haplogroup_id BIGINT REFERENCES tree.haplogroup(id), + old_values JSONB, + new_values JSONB, + status TEXT NOT NULL DEFAULT 'PENDING' -- PENDING/APPROVED/REJECTED +); +CREATE INDEX tree_change_set_idx ON tree.tree_change (change_set_id, status); + +CREATE TABLE tree.change_set_comment ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + change_set_id BIGINT NOT NULL REFERENCES tree.change_set(id) ON DELETE CASCADE, + commented_by TEXT NOT NULL, + comment TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- WIP shadow tables hold proposed structure before it is applied to production. +CREATE TABLE tree.wip_haplogroup ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + change_set_id BIGINT NOT NULL REFERENCES tree.change_set(id) ON DELETE CASCADE, + placeholder_id INTEGER NOT NULL, -- negative temp id within the change set + name TEXT NOT NULL, + source TEXT, + formed_ybp INTEGER, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb +); +CREATE INDEX wip_haplogroup_cs_idx ON tree.wip_haplogroup (change_set_id); + +CREATE TABLE tree.wip_relationship ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + change_set_id BIGINT NOT NULL REFERENCES tree.change_set(id) ON DELETE CASCADE, + child_placeholder_id INTEGER NOT NULL, + parent_placeholder_id INTEGER, + parent_production_id BIGINT REFERENCES tree.haplogroup(id) +); + +CREATE TABLE tree.wip_haplogroup_variant ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + change_set_id BIGINT NOT NULL REFERENCES tree.change_set(id) ON DELETE CASCADE, + wip_haplogroup_id BIGINT NOT NULL REFERENCES tree.wip_haplogroup(id) ON DELETE CASCADE, + variant_id BIGINT NOT NULL REFERENCES core.variant(id) +); + +CREATE TABLE tree.wip_reparent ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + change_set_id BIGINT NOT NULL REFERENCES tree.change_set(id) ON DELETE CASCADE, + haplogroup_id BIGINT NOT NULL REFERENCES tree.haplogroup(id), + old_parent_id BIGINT REFERENCES tree.haplogroup(id), + new_parent_id BIGINT REFERENCES tree.haplogroup(id) +); + +CREATE TABLE tree.wip_resolution ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + change_set_id BIGINT NOT NULL REFERENCES tree.change_set(id) ON DELETE CASCADE, + wip_haplogroup_id BIGINT REFERENCES tree.wip_haplogroup(id) ON DELETE CASCADE, + wip_reparent_id BIGINT REFERENCES tree.wip_reparent(id) ON DELETE CASCADE, + resolution_type TEXT NOT NULL, -- REPARENT/EDIT_VARIANTS/MERGE_EXISTING/DEFER + new_parent_id BIGINT REFERENCES tree.haplogroup(id), + merge_target_id BIGINT REFERENCES tree.haplogroup(id), + details JSONB NOT NULL DEFAULT '{}'::jsonb +); + +-- ── Discovery pipeline ─────────────────────────────────────────────────────── +-- Private variants found in a sample. Was polymorphic (sample_type, sample_id); +-- now one core.biosample(sample_guid) FK. +CREATE TABLE tree.biosample_private_variant ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sample_guid UUID NOT NULL REFERENCES core.biosample(sample_guid), + variant_id BIGINT NOT NULL REFERENCES core.variant(id), + haplogroup_type core.dna_type NOT NULL, + terminal_haplogroup_id BIGINT REFERENCES tree.haplogroup(id), + status TEXT NOT NULL DEFAULT 'ACTIVE', -- ACTIVE/PROMOTED/INVALIDATED + discovered_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX bpv_sample_idx ON tree.biosample_private_variant (sample_guid); +CREATE INDEX bpv_variant_idx ON tree.biosample_private_variant (variant_id); + +CREATE TABLE tree.proposed_branch ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + proposed_name TEXT, + parent_haplogroup_id BIGINT REFERENCES tree.haplogroup(id), + discovery_sample_guids UUID[] NOT NULL DEFAULT '{}', + evidence_count INTEGER NOT NULL DEFAULT 0, + confidence NUMERIC(4,3), + proposed_by TEXT, + status TEXT NOT NULL DEFAULT 'PROPOSED' -- PROPOSED/UNDER_REVIEW/REJECTED/ACCEPTED +); + +CREATE TABLE tree.proposed_branch_variant ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + proposed_branch_id BIGINT NOT NULL REFERENCES tree.proposed_branch(id) ON DELETE CASCADE, + variant_id BIGINT NOT NULL REFERENCES core.variant(id), + supporting_sample_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE tree.proposed_branch_evidence ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + proposed_branch_id BIGINT NOT NULL REFERENCES tree.proposed_branch(id) ON DELETE CASCADE, + evidence_type TEXT NOT NULL, -- PRIVATE_VARIANT/SHARED_DERIVED/STRVAL_SIMILARITY/PUBLICATION + evidence_detail JSONB NOT NULL DEFAULT '{}'::jsonb, + confidence NUMERIC(4,3) +); + +CREATE TABLE tree.curator_action ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + proposed_branch_id BIGINT REFERENCES tree.proposed_branch(id) ON DELETE CASCADE, + action TEXT NOT NULL, -- APPROVE/DEFER/REJECT + notes TEXT, + action_by TEXT, + action_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE tree.discovery_config ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + config_key TEXT NOT NULL UNIQUE, + config_value JSONB NOT NULL DEFAULT '{}'::jsonb, + description TEXT +); diff --git a/rust/migrations/0004_genomics.sql b/rust/migrations/0004_genomics.sql new file mode 100644 index 0000000..5a173ff --- /dev/null +++ b/rust/migrations/0004_genomics.sql @@ -0,0 +1,220 @@ +-- genomics schema: sequencing runs/files, coverage, callable loci, labs & +-- instruments, pangenome. De-sprawl (plan §2): +-- * sequence_file_checksum / _http_location / _atp_location -> JSONB on sequence_file +-- * alignment_coverage / pangenome_alignment_coverage -> coverage JSONB on metadata +-- * biosample_callable_loci polymorphic (sample_type, sample_id) -> sample_guid FK +-- * scattered at_uri/at_cid -> single `atproto` JSONB + +-- ── Reference contigs ──────────────────────────────────────────────────────── +CREATE TABLE genomics.genbank_contig ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + accession TEXT NOT NULL UNIQUE, + common_name TEXT, + reference_genome TEXT NOT NULL, -- GRCh37/GRCh38/hs1 + seq_length BIGINT +); + +-- ── Labs & instruments ─────────────────────────────────────────────────────── +CREATE TABLE genomics.sequencing_lab ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + is_d2c BOOLEAN NOT NULL DEFAULT false, + website_url TEXT, + description_markdown TEXT +); + +CREATE TABLE genomics.sequencer_instrument ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + instrument_id TEXT NOT NULL UNIQUE, -- e.g. 'A00123' + model_name TEXT, + manufacturer TEXT, + year_introduced INTEGER, + estimated_max_throughput BIGINT +); + +CREATE TABLE genomics.instrument_observation ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + instrument_id BIGINT REFERENCES genomics.sequencer_instrument(id), + lab_name TEXT, + biosample_ref TEXT, + platform TEXT, + instrument_model TEXT, + flowcell_id TEXT, + run_date DATE, + confidence TEXT, -- KNOWN/INFERRED/GUESSED + atproto JSONB -- {uri, cid, repo_did} +); +CREATE UNIQUE INDEX instrument_observation_atproto_uri_key + ON genomics.instrument_observation ((atproto->>'uri')) WHERE atproto IS NOT NULL; + +CREATE TABLE genomics.instrument_association_proposal ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + instrument_id BIGINT REFERENCES genomics.sequencer_instrument(id), + proposed_lab_name TEXT, + proposed_model TEXT, + observation_count INTEGER NOT NULL DEFAULT 0, + distinct_citizen_count INTEGER NOT NULL DEFAULT 0, + confidence_score NUMERIC(5,4), + status TEXT NOT NULL DEFAULT 'PENDING', + accepted_lab_id BIGINT REFERENCES genomics.sequencing_lab(id), + accepted_instrument_id BIGINT REFERENCES genomics.sequencer_instrument(id) +); + +-- ── Test types & coverage expectations ────────────────────────────────────── +CREATE TABLE genomics.test_type_definition ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + category core.data_generation_method NOT NULL, + vendor TEXT, + target_type core.target_type, + expected_min_depth DOUBLE PRECISION, + supports_haplogroup_y BOOLEAN NOT NULL DEFAULT false, + supports_haplogroup_mt BOOLEAN NOT NULL DEFAULT false, + supports_autosomal_ibd BOOLEAN NOT NULL DEFAULT false, + supports_ancestry BOOLEAN NOT NULL DEFAULT false, + typical_file_formats TEXT[] NOT NULL DEFAULT '{}', + description TEXT +); + +CREATE TABLE genomics.coverage_expectation_profile ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + test_type_id BIGINT NOT NULL REFERENCES genomics.test_type_definition(id) ON DELETE CASCADE, + contig_name TEXT, + variant_class TEXT, -- SNP/STR/INDEL + min_depth_high DOUBLE PRECISION, + min_depth_medium DOUBLE PRECISION, + min_depth_low DOUBLE PRECISION, + min_coverage_pct DOUBLE PRECISION, + min_mapping_quality DOUBLE PRECISION +); + +-- ── Pangenome ─────────────────────────────────────────────────────────────── +CREATE TABLE genomics.pangenome_graph ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + graph_name TEXT NOT NULL UNIQUE, + source_gfa_file TEXT, + description TEXT, + creation_date TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE genomics.pangenome_path ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + graph_id BIGINT NOT NULL REFERENCES genomics.pangenome_graph(id) ON DELETE CASCADE, + path_name TEXT NOT NULL, + is_reference BOOLEAN NOT NULL DEFAULT false, + length_bp BIGINT, + UNIQUE (graph_id, path_name) +); + +CREATE TABLE genomics.canonical_pangenome_variant ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + pangenome_graph_id BIGINT NOT NULL REFERENCES genomics.pangenome_graph(id) ON DELETE CASCADE, + variant_type TEXT, + variant_nodes INTEGER[] NOT NULL DEFAULT '{}', + variant_edges INTEGER[] NOT NULL DEFAULT '{}', + reference_path_id BIGINT REFERENCES genomics.pangenome_path(id), + reference_allele_sequence TEXT, + canonical_hash TEXT NOT NULL UNIQUE +); + +-- ── Sequencing runs & files ───────────────────────────────────────────────── +CREATE TABLE genomics.sequence_library ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sample_guid UUID NOT NULL REFERENCES core.biosample(sample_guid), + test_type_id BIGINT REFERENCES genomics.test_type_definition(id), + lab_id BIGINT REFERENCES genomics.sequencing_lab(id), + run_date DATE, + instrument TEXT, + reads BIGINT, + read_length INTEGER, + paired_end BOOLEAN, + insert_size INTEGER, + atproto JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX sequence_library_sample_idx ON genomics.sequence_library (sample_guid); + +CREATE TABLE genomics.sequence_file ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + library_id BIGINT NOT NULL REFERENCES genomics.sequence_library(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_size_bytes BIGINT, + file_format TEXT, -- BAM/CRAM/VCF/... + aligner TEXT, + target_reference TEXT, + pangenome_graph_id BIGINT REFERENCES genomics.pangenome_graph(id), + checksums JSONB NOT NULL DEFAULT '[]'::jsonb, -- [{algorithm, checksum, verified_at}] + http_locations JSONB NOT NULL DEFAULT '[]'::jsonb, -- [{file_url, file_index_url}] + atp_location JSONB, -- {repo_did, record_cid, record_path} + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX sequence_file_library_idx ON genomics.sequence_file (library_id); + +-- Linear-reference alignment stats. coverage JSONB replaces alignment_coverage; +-- expression indexes target the hot aggregation paths. +CREATE TABLE genomics.alignment_metadata ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sequence_file_id BIGINT NOT NULL REFERENCES genomics.sequence_file(id) ON DELETE CASCADE, + genbank_contig_id BIGINT REFERENCES genomics.genbank_contig(id), + metric_level TEXT NOT NULL, -- CONTIG_OVERALL/REGION + region_name TEXT, + region_start_pos BIGINT, + region_end_pos BIGINT, + reference_build TEXT, + variant_caller TEXT, + coverage JSONB NOT NULL DEFAULT '{}'::jsonb -- {meanDepth, medianDepth, percent_coverage_at_*x} +); +CREATE INDEX alignment_metadata_file_idx ON genomics.alignment_metadata (sequence_file_id); +CREATE INDEX alignment_metadata_meandepth_idx + ON genomics.alignment_metadata (((coverage->>'meanDepth')::double precision)); + +CREATE TABLE genomics.pangenome_alignment_metadata ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sequence_file_id BIGINT NOT NULL REFERENCES genomics.sequence_file(id) ON DELETE CASCADE, + pangenome_graph_id BIGINT NOT NULL REFERENCES genomics.pangenome_graph(id), + metric_level TEXT NOT NULL, -- GRAPH_OVERALL/PATH/NODE/REGION + metadata JSONB NOT NULL DEFAULT '{}'::jsonb -- includes coverage +); + +CREATE TABLE genomics.reported_variant_pangenome ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sample_guid UUID NOT NULL REFERENCES core.biosample(sample_guid), + graph_id BIGINT NOT NULL REFERENCES genomics.pangenome_graph(id), + variant_type TEXT, + variant_nodes INTEGER[] NOT NULL DEFAULT '{}', + variant_edges INTEGER[] NOT NULL DEFAULT '{}', + allele_fraction DOUBLE PRECISION, + depth INTEGER, + zygosity TEXT, -- HOM_REF/HET/HOM_ALT + haplotype_information JSONB NOT NULL DEFAULT '{}'::jsonb +); +CREATE INDEX reported_variant_pangenome_sample_idx ON genomics.reported_variant_pangenome (sample_guid); + +-- ── Chip genotype data ─────────────────────────────────────────────────────── +CREATE TABLE genomics.genotype_data ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sample_guid UUID NOT NULL REFERENCES core.biosample(sample_guid), + test_type_id BIGINT REFERENCES genomics.test_type_definition(id), + provider TEXT, + metrics JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX genotype_data_sample_idx ON genomics.genotype_data (sample_guid); + +-- ── Callable loci (was polymorphic; now sample_guid FK) ────────────────────── +CREATE TABLE genomics.biosample_callable_loci ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sample_guid UUID NOT NULL REFERENCES core.biosample(sample_guid), + chromosome TEXT NOT NULL, + total_callable_bp BIGINT NOT NULL DEFAULT 0, + region_count INTEGER NOT NULL DEFAULT 0, + bed_file_hash TEXT, + source_test_type_id BIGINT REFERENCES genomics.test_type_definition(id), + y_xdegen_callable_bp BIGINT, + y_ampliconic_callable_bp BIGINT, + y_palindromic_callable_bp BIGINT, + computed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX callable_loci_sample_idx ON genomics.biosample_callable_loci (sample_guid); diff --git a/rust/migrations/0005_ident.sql b/rust/migrations/0005_ident.sql new file mode 100644 index 0000000..7766d0c --- /dev/null +++ b/rust/migrations/0005_ident.sql @@ -0,0 +1,121 @@ +-- ident schema: users, RBAC, AT Protocol identity/OAuth, consent. + +CREATE TABLE ident.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email CITEXT UNIQUE, -- case-insensitive + did TEXT UNIQUE, -- AT Protocol DID + handle TEXT UNIQUE, + display_name TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ── RBAC ───────────────────────────────────────────────────────────────────── +CREATE TABLE ident.roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE ident.permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE ident.role_permissions ( + role_id UUID NOT NULL REFERENCES ident.roles(id) ON DELETE CASCADE, + permission_id UUID NOT NULL REFERENCES ident.permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) +); + +CREATE TABLE ident.user_roles ( + user_id UUID NOT NULL REFERENCES ident.users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES ident.roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); + +-- Base roles the app expects to exist (Admin, Curator, TreeCurator). +INSERT INTO ident.roles (name, description) VALUES + ('Admin', 'Full administrative access'), + ('Curator', 'Content curation'), + ('TreeCurator', 'Haplogroup tree curation') +ON CONFLICT (name) DO NOTHING; + +-- ── AT Protocol identity / OAuth ──────────────────────────────────────────── +CREATE TABLE ident.user_pds_info ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES ident.users(id) ON DELETE CASCADE, + pds_url VARCHAR(512) NOT NULL, + did TEXT UNIQUE, + handle TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE ident.user_login_info ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES ident.users(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL, + provider_key TEXT NOT NULL, + -- bcrypt for legacy verification, argon2 for new hashes (NULL for OAuth-only). + password_hash TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (provider_id, provider_key) +); + +CREATE TABLE ident.user_oauth2_info ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + login_info_id UUID NOT NULL UNIQUE REFERENCES ident.user_login_info(id) ON DELETE CASCADE, + access_token TEXT NOT NULL, + token_type TEXT, + expires_in BIGINT, + refresh_token TEXT, + scope TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE ident.atprotocol_authorization_servers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issuer_url TEXT NOT NULL UNIQUE, + authorization_endpoint TEXT, + token_endpoint TEXT, + pushed_authorization_request_endpoint TEXT, + dpop_signing_alg_values_supported TEXT, + scopes_supported TEXT, + metadata_fetched_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE ident.atprotocol_client_metadata ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id_url TEXT NOT NULL UNIQUE, + client_name TEXT, + logo_uri TEXT, + tos_uri TEXT, + policy_uri TEXT, + redirect_uris TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ── GDPR cookie consent (authenticated or anonymous) ───────────────────────── +CREATE TABLE ident.cookie_consents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES ident.users(id) ON DELETE SET NULL, + session_id TEXT, + ip_address_hash VARCHAR(64), + consent_given BOOLEAN NOT NULL DEFAULT false, + consent_timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + policy_version TEXT, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/rust/migrations/0006_pubs.sql b/rust/migrations/0006_pubs.sql new file mode 100644 index 0000000..643fe83 --- /dev/null +++ b/rust/migrations/0006_pubs.sql @@ -0,0 +1,81 @@ +-- pubs schema: publications, genomic studies, and their links to samples. +-- De-sprawl: publication_biosample + publication_citizen_biosample collapse into +-- one link table now that biosamples are unified under core.biosample. + +CREATE TYPE pubs.study_source AS ENUM ('ENA', 'NCBI_BIOPROJECT', 'NCBI_GENBANK'); + +CREATE TABLE pubs.genomic_study ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + accession TEXT NOT NULL UNIQUE, + title TEXT, + center_name TEXT, + study_name TEXT, + source pubs.study_source NOT NULL DEFAULT 'ENA', + bio_project_id TEXT, + molecule TEXT, + topology TEXT, + taxonomy_id INTEGER, + version INTEGER, + submission_date DATE, + details JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE TABLE pubs.publication ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + pubmed_id TEXT UNIQUE, + doi TEXT UNIQUE, + open_alex_id TEXT UNIQUE, + title TEXT NOT NULL, + journal TEXT, + publication_date DATE, + url TEXT, + authors TEXT, + abstract_summary TEXT, + citation_normalized_percentile NUMERIC, + cited_by_count INTEGER, + open_access_status TEXT, + open_access_url TEXT, + primary_topic TEXT, + publication_type TEXT, + publisher TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Unified publication<->biosample link (both standard and citizen samples). +CREATE TABLE pubs.publication_biosample ( + publication_id BIGINT NOT NULL REFERENCES pubs.publication(id) ON DELETE CASCADE, + sample_guid UUID NOT NULL REFERENCES core.biosample(sample_guid) ON DELETE CASCADE, + PRIMARY KEY (publication_id, sample_guid) +); + +CREATE TABLE pubs.publication_study ( + publication_id BIGINT NOT NULL REFERENCES pubs.publication(id) ON DELETE CASCADE, + study_id BIGINT NOT NULL REFERENCES pubs.genomic_study(id) ON DELETE CASCADE, + PRIMARY KEY (publication_id, study_id) +); + +-- Editorial review queue from OpenAlex discovery. +CREATE TABLE pubs.publication_candidate ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + openalex_id TEXT NOT NULL UNIQUE, + doi TEXT, + title TEXT, + abstract TEXT, + publication_date DATE, + journal_name TEXT, + relevance_score NUMERIC, + status TEXT NOT NULL DEFAULT 'pending', -- pending/accepted/rejected/deferred + reviewed_by UUID REFERENCES ident.users(id), + raw_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX publication_candidate_status_idx ON pubs.publication_candidate (status); + +CREATE TABLE pubs.publication_search_config ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + search_query TEXT, + concepts JSONB NOT NULL DEFAULT '[]'::jsonb, + enabled BOOLEAN NOT NULL DEFAULT true +); diff --git a/rust/migrations/0007_ibd.sql b/rust/migrations/0007_ibd.sql new file mode 100644 index 0000000..82cdab2 --- /dev/null +++ b/rust/migrations/0007_ibd.sql @@ -0,0 +1,126 @@ +-- ibd schema: population/ancestry analysis + privacy-preserving IBD matching, +-- attestations, suggestions, and match request/consent tracking. + +-- ── Population & ancestry ──────────────────────────────────────────────────── +CREATE TABLE ibd.population ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + population_name TEXT NOT NULL UNIQUE +); + +CREATE TABLE ibd.analysis_method ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + method_name TEXT NOT NULL UNIQUE +); + +CREATE TABLE ibd.ancestry_analysis ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sample_guid UUID NOT NULL REFERENCES core.biosample(sample_guid) ON DELETE CASCADE, + analysis_method_id BIGINT NOT NULL REFERENCES ibd.analysis_method(id), + population_id BIGINT NOT NULL REFERENCES ibd.population(id), + probability NUMERIC(5,4) NOT NULL, + UNIQUE (sample_guid, analysis_method_id, population_id) +); + +-- Full ADMIXTURE/PCA breakdown per sample (pca_coordinates as JSONB). +CREATE TABLE ibd.population_breakdown ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sample_guid UUID NOT NULL REFERENCES core.biosample(sample_guid) ON DELETE CASCADE, + analysis_method TEXT, + panel_type TEXT, + pca_coordinates JSONB NOT NULL DEFAULT '{}'::jsonb, + analysis_date TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX population_breakdown_sample_idx ON ibd.population_breakdown (sample_guid); + +CREATE TABLE ibd.population_breakdown_cache ( + sample_guid UUID PRIMARY KEY REFERENCES core.biosample(sample_guid) ON DELETE CASCADE, + breakdown JSONB NOT NULL DEFAULT '{}'::jsonb, + breakdown_hash VARCHAR(64), + cached_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Cached O(N^2) pairwise overlap scores (order-independent pair key). +CREATE TABLE ibd.population_overlap_score ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sample_guid_1 UUID NOT NULL REFERENCES core.biosample(sample_guid) ON DELETE CASCADE, + sample_guid_2 UUID NOT NULL REFERENCES core.biosample(sample_guid) ON DELETE CASCADE, + score DOUBLE PRECISION NOT NULL, + computed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX population_overlap_pair_key + ON ibd.population_overlap_score (LEAST(sample_guid_1, sample_guid_2), GREATEST(sample_guid_1, sample_guid_2)); + +-- ── IBD matching ───────────────────────────────────────────────────────────── +CREATE TABLE ibd.validation_service ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + guid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT, + trust_level INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE ibd.ibd_discovery_index ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + sample_guid_1 UUID NOT NULL REFERENCES core.biosample(sample_guid) ON DELETE CASCADE, + sample_guid_2 UUID NOT NULL REFERENCES core.biosample(sample_guid) ON DELETE CASCADE, + pangenome_graph_id BIGINT REFERENCES genomics.pangenome_graph(id), + match_region_type TEXT NOT NULL, -- AUTOSOMAL/X/Y/MT + total_shared_cm_approx DOUBLE PRECISION, + num_shared_segments_approx INTEGER, + is_publicly_discoverable BOOLEAN NOT NULL DEFAULT false, + consensus_status TEXT, + validation_service_id BIGINT REFERENCES ibd.validation_service(id), + indexed_date TIMESTAMPTZ NOT NULL DEFAULT now() +); +-- Order-independent pair uniqueness per region type. +CREATE UNIQUE INDEX ibd_discovery_pair_key + ON ibd.ibd_discovery_index (LEAST(sample_guid_1, sample_guid_2), GREATEST(sample_guid_1, sample_guid_2), match_region_type); + +CREATE TABLE ibd.ibd_pds_attestation ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + ibd_discovery_index_id BIGINT NOT NULL REFERENCES ibd.ibd_discovery_index(id) ON DELETE CASCADE, + attesting_pds_guid UUID NOT NULL, + attestation_timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + attestation_signature TEXT NOT NULL, + attestation_type TEXT NOT NULL, -- INITIAL_REPORT/CONFIRMATION/DISPUTE/REVOCATION + attestation_notes TEXT +); +CREATE INDEX ibd_attestation_index_idx ON ibd.ibd_pds_attestation (ibd_discovery_index_id); + +CREATE TABLE ibd.match_suggestion ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + target_sample_guid UUID NOT NULL REFERENCES core.biosample(sample_guid) ON DELETE CASCADE, + suggested_sample_guid UUID NOT NULL REFERENCES core.biosample(sample_guid) ON DELETE CASCADE, + suggestion_type TEXT NOT NULL, -- SHARED_MATCH/POPULATION_OVERLAP/HAPLOGROUP + score DOUBLE PRECISION, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'ACTIVE', -- ACTIVE/DISMISSED/EXPIRED/CONVERTED + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ +); +CREATE INDEX match_suggestion_target_idx ON ibd.match_suggestion (target_sample_guid, status); + +-- ── Match request & consent (AT Protocol records keyed by at:// URI) ───────── +CREATE TABLE ibd.match_request ( + request_uri TEXT PRIMARY KEY, + requester_did TEXT NOT NULL, + target_did TEXT NOT NULL, + requester_sample_guid UUID REFERENCES core.biosample(sample_guid), + target_sample_guid UUID REFERENCES core.biosample(sample_guid), + status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING/CANCELLED/CONSENTED/DECLINED/EXPIRED + details JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX match_request_target_did_idx ON ibd.match_request (target_did, status); +CREATE INDEX match_request_requester_did_idx ON ibd.match_request (requester_did, status); + +CREATE TABLE ibd.match_consent ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + request_uri TEXT NOT NULL REFERENCES ibd.match_request(request_uri) ON DELETE CASCADE, + consenting_did TEXT NOT NULL, + consent_given BOOLEAN NOT NULL, + consent_uri TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX match_consent_request_idx ON ibd.match_consent (request_uri); diff --git a/rust/migrations/0008_fed.sql b/rust/migrations/0008_fed.sql new file mode 100644 index 0000000..f4e0e75 --- /dev/null +++ b/rust/migrations/0008_fed.sql @@ -0,0 +1,75 @@ +-- fed schema: PDS fleet + firehose. This collapses the legacy second "metadata" +-- database into the single DB (plan §2) — one database, one pool. + +-- Firehose cursor/lease tracking per registered PDS (distributed consumers). +CREATE TABLE fed.pds_registration ( + did TEXT PRIMARY KEY, + pds_url TEXT NOT NULL, + handle TEXT, + last_commit_cid TEXT, + cursor BIGINT, + leased_by_instance_id TEXT, + lease_expires_at TIMESTAMPTZ, + processing_status TEXT NOT NULL DEFAULT 'IDLE', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX pds_registration_lease_idx ON fed.pds_registration (lease_expires_at); + +CREATE TABLE fed.pds_node ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + did TEXT NOT NULL UNIQUE, + pds_url TEXT, + handle TEXT, + node_name TEXT, + software_version TEXT, + status TEXT NOT NULL DEFAULT 'UNKNOWN', -- ONLINE/OFFLINE/BUSY/ERROR/UNKNOWN + capabilities JSONB NOT NULL DEFAULT '{}'::jsonb, + last_heartbeat TIMESTAMPTZ, + last_commit_cid TEXT, + ip_address TEXT, + os_info TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX pds_node_status_idx ON fed.pds_node (status); + +CREATE TABLE fed.pds_heartbeat_log ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + pds_node_id BIGINT NOT NULL REFERENCES fed.pds_node(id) ON DELETE CASCADE, + status TEXT, + software_version TEXT, + load_metrics JSONB NOT NULL DEFAULT '{}'::jsonb, + processing_queue_size INTEGER, + error_message TEXT, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX pds_heartbeat_node_time_idx ON fed.pds_heartbeat_log (pds_node_id, recorded_at DESC); + +CREATE TABLE fed.pds_fleet_config ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + config_key TEXT NOT NULL UNIQUE, + config_value TEXT, + description TEXT, + updated_by TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Distributed variant/haplogroup/STR proposals submitted by edge nodes. +CREATE TABLE fed.pds_submission ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + pds_node_id BIGINT REFERENCES fed.pds_node(id) ON DELETE SET NULL, + submission_type TEXT NOT NULL, -- HAPLOGROUP_CALL/VARIANT_CALL/BRANCH_PROPOSAL/PRIVATE_VARIANT/STR_PROFILE + biosample_guid UUID REFERENCES core.biosample(sample_guid), + proposed_value TEXT, + confidence_score NUMERIC(5,4), + algorithm_version TEXT, + software_version TEXT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING/ACCEPTED/REJECTED/SUPERSEDED + reviewed_by TEXT, + reviewed_at TIMESTAMPTZ, + atproto JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX pds_submission_status_type_idx ON fed.pds_submission (status, submission_type); diff --git a/rust/migrations/0009_social_support_billing.sql b/rust/migrations/0009_social_support_billing.sql new file mode 100644 index 0000000..061a78a --- /dev/null +++ b/rust/migrations/0009_social_support_billing.sql @@ -0,0 +1,130 @@ +-- social / support / billing schemas. + +-- ── social: reputation ────────────────────────────────────────────────────── +CREATE TABLE social.reputation_event_type ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT, + default_points_change INTEGER NOT NULL DEFAULT 0, + is_positive BOOLEAN NOT NULL DEFAULT true, + is_system_generated BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE social.reputation_event ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES ident.users(id) ON DELETE CASCADE, + event_type_id UUID NOT NULL REFERENCES social.reputation_event_type(id), + actual_points_change INTEGER NOT NULL, + source_user_id UUID REFERENCES ident.users(id) ON DELETE SET NULL, + related_entity_type TEXT, + related_entity_id UUID, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX reputation_event_user_idx ON social.reputation_event (user_id, created_at DESC); + +CREATE TABLE social.user_reputation_score ( + user_id UUID PRIMARY KEY REFERENCES ident.users(id) ON DELETE CASCADE, + score BIGINT NOT NULL DEFAULT 0, + last_calculated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ── social: messaging & feed ───────────────────────────────────────────────── +CREATE TABLE social.user_block ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + blocker_id UUID NOT NULL REFERENCES ident.users(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES ident.users(id) ON DELETE CASCADE, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (blocker_id, blocked_id) +); + +CREATE TABLE social.conversation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + participant_ids UUID[] NOT NULL DEFAULT '{}', + subject TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_message_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ +); +CREATE INDEX conversation_participants_gin ON social.conversation USING gin (participant_ids); + +CREATE TABLE social.message ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES social.conversation(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES ident.users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + read_at TIMESTAMPTZ +); +CREATE INDEX message_conversation_idx ON social.message (conversation_id, created_at); + +CREATE TABLE social.feed_post ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + author_id UUID NOT NULL REFERENCES ident.users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- ── social: group projects (legacy public.group_project) ───────────────────── +-- Rich access-control policies kept as TEXT (flexible) + atproto JSONB. +CREATE TABLE social.group_project ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_guid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + project_name TEXT NOT NULL, + project_type TEXT NOT NULL, -- HAPLOGROUP/SURNAME/GEOGRAPHIC/ETHNIC/RESEARCH/CUSTOM + target_haplogroup TEXT, + target_lineage TEXT, -- Y_DNA/MT_DNA/BOTH + description TEXT, + join_policy TEXT NOT NULL DEFAULT 'OPEN', + member_list_visibility TEXT NOT NULL DEFAULT 'MEMBERS_ONLY', + str_policy TEXT, + snp_policy TEXT, + public_tree_view BOOLEAN NOT NULL DEFAULT false, + succession_policy TEXT, + owner_did TEXT, + atproto JSONB, + deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ── support: contact messages (authenticated or anonymous) ─────────────────── +CREATE TABLE support.contact_message ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES ident.users(id) ON DELETE SET NULL, + sender_name TEXT, + sender_email TEXT, + subject TEXT, + message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'new', -- new/read/replied/closed + ip_address_hash VARCHAR(64), + user_last_viewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX contact_message_status_idx ON support.contact_message (status, created_at DESC); +CREATE INDEX contact_message_user_idx ON support.contact_message (user_id); + +-- ── billing: subscriptions ─────────────────────────────────────────────────── +CREATE TABLE billing.patron_subscription ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id UUID NOT NULL REFERENCES ident.users(id) ON DELETE CASCADE, + patron_tier TEXT NOT NULL, -- SUPPORTER/CONTRIBUTOR/SUSTAINER/FOUNDING_PATRON + status TEXT NOT NULL DEFAULT 'ACTIVE', -- ACTIVE/CANCELLED/PAST_DUE/EXPIRED + payment_provider TEXT, -- STRIPE/PAYPAL + provider_subscription_id TEXT, + amount_cents INTEGER, + currency TEXT NOT NULL DEFAULT 'USD', + billing_interval TEXT, -- MONTHLY/YEARLY + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX patron_subscription_user_idx ON billing.patron_subscription (user_id, status); diff --git a/rust/scripts/test-db.sh b/rust/scripts/test-db.sh new file mode 100755 index 0000000..d609578 --- /dev/null +++ b/rust/scripts/test-db.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Local Postgres (PostGIS) for tests/dev on a Docker-less Apple-Silicon Mac. +# +# Uses Apple's `container` CLI by default (this Mac Studio has no Docker). Apple +# `container` gives each container its own routable IP (no localhost port +# forwarding), so `up` discovers that IP and prints the matching DATABASE_URL. +# The same PostGIS image is used in production via compose.yaml. +# +# eval "$(./scripts/test-db.sh up)" start, wait, migrate, export DATABASE_URL +# ./scripts/test-db.sh down stop and remove the container +# ./scripts/test-db.sh reset down then up +# ./scripts/test-db.sh url print DATABASE_URL for the running container +# +# If $DATABASE_URL is already set, `up`/`url` use it as-is and DO NOT start a +# container (native-Postgres fallback — see plan §9). +set -euo pipefail + +NAME="${DU_PG_NAME:-du-pg}" +# imresamu/postgis publishes linux/arm64 (the official postgis/postgis is amd64-only +# and Apple `container` runs arm64 VMs without emulation). +IMAGE="${DU_PG_IMAGE:-docker.io/imresamu/postgis:16-3.4}" +PASSWORD="${DU_PG_PASSWORD:-dev}" +DB="${DU_PG_DB:-decodingus}" +USER="${DU_PG_USER:-postgres}" +HERE="$(cd "$(dirname "$0")" && pwd)" + +runtime() { + if command -v container >/dev/null 2>&1; then echo "container" + elif command -v docker >/dev/null 2>&1; then echo "docker" + else echo ""; fi +} + +require_runtime() { + local rt; rt="$(runtime)" + if [ -z "$rt" ]; then + cat >&2 <<'EOF' +ERROR: neither `container` (Apple) nor `docker` is installed. + Apple container: https://github.com/apple/container/releases + Or set DATABASE_URL to an existing Postgres and skip the container entirely. +EOF + exit 1 + fi + echo "$rt" +} + +# Resolve host:port for the running container under the given runtime. +container_host() { + local rt="$1" + if [ "$rt" = "container" ]; then + # Each container has its own IP; read it from `container ls`. + container ls 2>/dev/null | awk -v n="$NAME" '$1==n {print $6}' | cut -d/ -f1 + else + echo "localhost" + fi +} + +build_url() { echo "postgres://${USER}:${PASSWORD}@${1}:5432/${DB}?sslmode=disable"; } + +wait_ready() { + local host="$1" + echo "waiting for postgres on ${host}:5432 ..." >&2 + for _ in $(seq 1 60); do + if timeout 2 bash -c "(exec 3<>/dev/tcp/${host}/5432)" 2>/dev/null; then + sleep 1 + echo "postgres is accepting connections." >&2 + return 0 + fi + sleep 1 + done + echo "ERROR: postgres did not become ready in time" >&2 + exit 1 +} + +apply_migrations() { + local url="$1" + if command -v sqlx >/dev/null 2>&1; then + echo "applying migrations via sqlx-cli ..." >&2 + DATABASE_URL="$url" sqlx migrate run --source "${HERE}/../migrations" >&2 + else + echo "NOTE: sqlx-cli not found — migrations not auto-applied." >&2 + echo " \`cargo test\` applies them via du_db::run_migrations, or install sqlx-cli." >&2 + fi +} + +cmd="${1:-up}" +case "$cmd" in + up) + if [ -n "${DATABASE_URL:-}" ]; then + wait_ready "$(echo "$DATABASE_URL" | sed -E 's#.*@([^:/]+).*#\1#')" + apply_migrations "$DATABASE_URL" + echo "export DATABASE_URL=\"$DATABASE_URL\"" + exit 0 + fi + rt="$(require_runtime)" + echo "starting $NAME ($IMAGE) via $rt ..." >&2 + if [ "$rt" = "container" ]; then + container run -d --name "$NAME" \ + -e POSTGRES_PASSWORD="$PASSWORD" -e POSTGRES_DB="$DB" "$IMAGE" >/dev/null + else + docker run -d --name "$NAME" -p 5432:5432 \ + -e POSTGRES_PASSWORD="$PASSWORD" -e POSTGRES_DB="$DB" "$IMAGE" >/dev/null + fi + # Give Apple `container` a moment to assign an IP. + host=""; for _ in $(seq 1 20); do host="$(container_host "$rt")"; [ -n "$host" ] && break; sleep 1; done + [ -n "$host" ] || { echo "ERROR: could not resolve container IP" >&2; exit 1; } + url="$(build_url "$host")" + wait_ready "$host" + apply_migrations "$url" + echo "export DATABASE_URL=\"$url\"" + ;; + down) + rt="$(require_runtime)" + "$rt" rm -f "$NAME" >/dev/null 2>&1 || true + echo "removed $NAME" >&2 + ;; + reset) + "$0" down || true + "$0" up + ;; + url) + if [ -n "${DATABASE_URL:-}" ]; then echo "$DATABASE_URL"; exit 0; fi + rt="$(require_runtime)"; host="$(container_host "$rt")" + [ -n "$host" ] || { echo "ERROR: container '$NAME' not running" >&2; exit 1; } + build_url "$host" + ;; + *) + echo "usage: $0 {up|down|reset|url}" >&2 + exit 2 + ;; +esac From 0eeb394dc1c7ac11d340c781ff412ecb3b4e0d8e Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 07:22:40 -0500 Subject: [PATCH 002/191] feat(rust): du-db query modules + read-side domain types Data-access layer for the public read surface, with a reusable mapping pattern: JSONB columns decode into du-domain payload structs via sqlx Json; Postgres enums are read as ::text and parsed through serde (parse_pg_enum / pg_enum_label), keeping du-domain free of any sqlx dependency. - du-domain: Haplogroup, Publication, Biosample read-side types. - du-db modules: * variant - get_by_id, paginated search (canonical name + common_names/rs_ids JSONB alias arrays) * haplogroup- get_by_id/by_name, children, roots (current edges, valid_until IS NULL) * publication - get_by_id, paginated search (title/journal/doi, newest-first) * biosample - get_by_guid, find_by_alias_or_accession * pagination - generic Page - tests/queries.rs: seeds sentinel rows, exercises every module against live PostGIS, cleans up. Full workspace 7/7 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/du-db/src/biosample.rs | 61 ++++++++++ rust/crates/du-db/src/haplogroup.rs | 101 ++++++++++++++++ rust/crates/du-db/src/lib.rs | 31 +++++ rust/crates/du-db/src/pagination.rs | 27 +++++ rust/crates/du-db/src/publication.rs | 98 +++++++++++++++ rust/crates/du-db/src/variant.rs | 96 +++++++++++++++ rust/crates/du-db/tests/queries.rs | 145 +++++++++++++++++++++++ rust/crates/du-domain/src/biosample.rs | 22 ++++ rust/crates/du-domain/src/haplogroup.rs | 19 +++ rust/crates/du-domain/src/lib.rs | 3 + rust/crates/du-domain/src/publication.rs | 19 +++ 11 files changed, 622 insertions(+) create mode 100644 rust/crates/du-db/src/biosample.rs create mode 100644 rust/crates/du-db/src/haplogroup.rs create mode 100644 rust/crates/du-db/src/pagination.rs create mode 100644 rust/crates/du-db/src/publication.rs create mode 100644 rust/crates/du-db/src/variant.rs create mode 100644 rust/crates/du-db/tests/queries.rs create mode 100644 rust/crates/du-domain/src/biosample.rs create mode 100644 rust/crates/du-domain/src/haplogroup.rs create mode 100644 rust/crates/du-domain/src/publication.rs diff --git a/rust/crates/du-db/src/biosample.rs b/rust/crates/du-db/src/biosample.rs new file mode 100644 index 0000000..2d1f7cb --- /dev/null +++ b/rust/crates/du-db/src/biosample.rs @@ -0,0 +1,61 @@ +//! Queries for the unified `core.biosample`. + +use crate::{parse_pg_enum, DbError}; +use du_domain::biosample::Biosample; +use du_domain::ids::SampleGuid; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(sqlx::FromRow)] +struct BiosampleRow { + sample_guid: Uuid, + source: String, + accession: Option, + alias: Option, + description: Option, + center_name: Option, + locked: bool, + source_attrs: serde_json::Value, + atproto: Option, +} + +impl BiosampleRow { + fn into_domain(self) -> Result { + Ok(Biosample { + sample_guid: SampleGuid(self.sample_guid), + source: parse_pg_enum(&self.source, "source")?, + accession: self.accession, + alias: self.alias, + description: self.description, + center_name: self.center_name, + locked: self.locked, + source_attrs: self.source_attrs, + atproto: self.atproto, + }) + } +} + +const SELECT: &str = "SELECT sample_guid, source::text AS source, accession, alias, description, \ + center_name, locked, source_attrs, atproto FROM core.biosample WHERE deleted = false"; + +pub async fn get_by_guid(pool: &PgPool, guid: SampleGuid) -> Result, DbError> { + let row: Option = sqlx::query_as(&format!("{SELECT} AND sample_guid = $1")) + .bind(guid.0) + .fetch_optional(pool) + .await?; + row.map(BiosampleRow::into_domain).transpose() +} + +/// Lookup by accession or alias (the private biosample search). +pub async fn find_by_alias_or_accession( + pool: &PgPool, + query: &str, +) -> Result, DbError> { + let like = format!("%{}%", query.trim()); + let rows: Vec = + sqlx::query_as(&format!("{SELECT} AND (accession ILIKE $1 OR alias ILIKE $1) ORDER BY accession LIMIT 50")) + .bind(&like) + .fetch_all(pool) + .await?; + rows.into_iter().map(BiosampleRow::into_domain).collect() +} diff --git a/rust/crates/du-db/src/haplogroup.rs b/rust/crates/du-db/src/haplogroup.rs new file mode 100644 index 0000000..c126dd2 --- /dev/null +++ b/rust/crates/du-db/src/haplogroup.rs @@ -0,0 +1,101 @@ +//! Queries for `tree.haplogroup` + current parent/child edges. These back the +//! Y/MT tree views. "Current" edges are those with `valid_until IS NULL`. + +use crate::{parse_pg_enum, pg_enum_label, DbError}; +use du_domain::enums::DnaType; +use du_domain::haplogroup::Haplogroup; +use du_domain::ids::HaplogroupId; +use sqlx::PgPool; + +#[derive(sqlx::FromRow)] +struct HaplogroupRow { + id: i64, + name: String, + haplogroup_type: String, + lineage: Option, + source: Option, + confidence_level: Option, + formed_ybp: Option, + tmrca_ybp: Option, + provenance: serde_json::Value, +} + +impl HaplogroupRow { + fn into_domain(self) -> Result { + Ok(Haplogroup { + id: HaplogroupId(self.id), + name: self.name, + haplogroup_type: parse_pg_enum(&self.haplogroup_type, "haplogroup_type")?, + lineage: self.lineage, + source: self.source, + confidence_level: self.confidence_level, + formed_ybp: self.formed_ybp, + tmrca_ybp: self.tmrca_ybp, + provenance: self.provenance, + }) + } +} + +// Columns qualified with alias `h` so joins (which also carry an `id`) stay +// unambiguous; output column names still match HaplogroupRow's fields. +const COLS: &str = "h.id, h.name, h.haplogroup_type::text AS haplogroup_type, h.lineage, h.source, \ + h.confidence_level, h.formed_ybp, h.tmrca_ybp, h.provenance"; + +fn collect(rows: Vec) -> Result, DbError> { + rows.into_iter().map(HaplogroupRow::into_domain).collect() +} + +pub async fn get_by_id(pool: &PgPool, id: HaplogroupId) -> Result, DbError> { + let row: Option = + sqlx::query_as(&format!("SELECT {COLS} FROM tree.haplogroup h WHERE h.id = $1")) + .bind(id.0) + .fetch_optional(pool) + .await?; + row.map(HaplogroupRow::into_domain).transpose() +} + +pub async fn get_by_name( + pool: &PgPool, + name: &str, + dna_type: DnaType, +) -> Result, DbError> { + let row: Option = sqlx::query_as(&format!( + "SELECT {COLS} FROM tree.haplogroup h WHERE h.name = $1 AND h.haplogroup_type::text = $2" + )) + .bind(name) + .bind(pg_enum_label(&dna_type)?) + .fetch_optional(pool) + .await?; + row.map(HaplogroupRow::into_domain).transpose() +} + +/// Direct children of a haplogroup (current edges), ordered by name. +pub async fn children(pool: &PgPool, parent: HaplogroupId) -> Result, DbError> { + let rows: Vec = sqlx::query_as(&format!( + "SELECT {COLS} FROM tree.haplogroup h \ + JOIN tree.haplogroup_relationship r ON r.child_haplogroup_id = h.id \ + WHERE r.parent_haplogroup_id = $1 AND r.valid_until IS NULL \ + ORDER BY h.name" + )) + .bind(parent.0) + .fetch_all(pool) + .await?; + collect(rows) +} + +/// Root haplogroups of a lineage: no current edge to a parent. +pub async fn roots(pool: &PgPool, dna_type: DnaType) -> Result, DbError> { + let rows: Vec = sqlx::query_as(&format!( + "SELECT {COLS} FROM tree.haplogroup h \ + WHERE h.haplogroup_type::text = $1 \ + AND NOT EXISTS ( \ + SELECT 1 FROM tree.haplogroup_relationship r \ + WHERE r.child_haplogroup_id = h.id AND r.parent_haplogroup_id IS NOT NULL \ + AND r.valid_until IS NULL) \ + ORDER BY h.name" + )) + .bind(pg_enum_label(&dna_type)?) + .fetch_all(pool) + .await?; + collect(rows) +} diff --git a/rust/crates/du-db/src/lib.rs b/rust/crates/du-db/src/lib.rs index f242e66..fd44f8a 100644 --- a/rust/crates/du-db/src/lib.rs +++ b/rust/crates/du-db/src/lib.rs @@ -7,12 +7,43 @@ use sqlx::postgres::{PgPool, PgPoolOptions}; use std::time::Duration; use thiserror::Error; +pub mod biosample; +pub mod haplogroup; +pub mod pagination; +pub mod publication; +pub mod variant; + +pub use pagination::Page; + #[derive(Debug, Error)] pub enum DbError { #[error("database error: {0}")] Sqlx(#[from] sqlx::Error), #[error("migration error: {0}")] Migrate(#[from] sqlx::migrate::MigrateError), + /// A row's text/JSONB column failed to decode into a domain type. + #[error("decode error: {0}")] + Decode(String), +} + +/// Decode a Postgres enum label (fetched as `::text`) into a domain enum that +/// derives `Deserialize` with matching SCREAMING_SNAKE_CASE variants. Keeps +/// du-domain free of any sqlx dependency. +pub(crate) fn parse_pg_enum( + label: &str, + what: &str, +) -> Result { + serde_json::from_value(serde_json::Value::String(label.to_string())) + .map_err(|e| DbError::Decode(format!("{what} = {label:?}: {e}"))) +} + +/// Inverse of `parse_pg_enum`: a domain enum's SCREAMING_SNAKE_CASE label for +/// binding against a `::text`-cast enum column. +pub(crate) fn pg_enum_label(value: &T) -> Result { + match serde_json::to_value(value).map_err(|e| DbError::Decode(e.to_string()))? { + serde_json::Value::String(s) => Ok(s), + other => Err(DbError::Decode(format!("expected enum string, got {other}"))), + } } /// Connect and return a pool. `database_url` is the standard `postgres://` DSN diff --git a/rust/crates/du-db/src/pagination.rs b/rust/crates/du-db/src/pagination.rs new file mode 100644 index 0000000..d24d889 --- /dev/null +++ b/rust/crates/du-db/src/pagination.rs @@ -0,0 +1,27 @@ +//! Pagination helper shared by list/search queries. + +use serde::Serialize; + +/// A page of results plus the totals the UI needs to render pagination controls. +#[derive(Debug, Clone, Serialize)] +pub struct Page { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +impl Page { + pub fn total_pages(&self) -> i64 { + if self.page_size <= 0 { + 0 + } else { + (self.total + self.page_size - 1) / self.page_size + } + } + + /// Clamp page/page_size to sane bounds and return the SQL OFFSET. + pub fn offset(page: i64, page_size: i64) -> i64 { + (page.max(1) - 1) * page_size.clamp(1, 200) + } +} diff --git a/rust/crates/du-db/src/publication.rs b/rust/crates/du-db/src/publication.rs new file mode 100644 index 0000000..7fbdc86 --- /dev/null +++ b/rust/crates/du-db/src/publication.rs @@ -0,0 +1,98 @@ +//! Queries for `pubs.publication` (the references listing). + +use crate::{DbError, Page}; +use du_domain::ids::PublicationId; +use du_domain::publication::Publication; +use sqlx::PgPool; + +#[derive(sqlx::FromRow)] +struct PublicationRow { + id: i64, + title: String, + doi: Option, + pubmed_id: Option, + journal: Option, + publication_date: Option, + authors: Option, + abstract_summary: Option, + url: Option, + cited_by_count: Option, + open_access_status: Option, +} + +impl From for Publication { + fn from(r: PublicationRow) -> Self { + Publication { + id: PublicationId(r.id), + title: r.title, + doi: r.doi, + pubmed_id: r.pubmed_id, + journal: r.journal, + publication_date: r.publication_date, + authors: r.authors, + abstract_summary: r.abstract_summary, + url: r.url, + cited_by_count: r.cited_by_count, + open_access_status: r.open_access_status, + } + } +} + +const SELECT: &str = "SELECT id, title, doi, pubmed_id, journal, publication_date, authors, \ + abstract_summary, url, cited_by_count, open_access_status FROM pubs.publication"; + +pub async fn get_by_id(pool: &PgPool, id: PublicationId) -> Result, DbError> { + let row: Option = sqlx::query_as(&format!("{SELECT} WHERE id = $1")) + .bind(id.0) + .fetch_optional(pool) + .await?; + Ok(row.map(Into::into)) +} + +/// Paginated list, optionally filtered by title/journal/DOI substring, newest first. +pub async fn search( + pool: &PgPool, + query: Option<&str>, + page: i64, + page_size: i64, +) -> Result, DbError> { + let offset = Page::<()>::offset(page, page_size); + let limit = page_size.clamp(1, 200); + let term = query.map(str::trim).filter(|q| !q.is_empty()); + + const FILTER: &str = "WHERE title ILIKE $1 OR journal ILIKE $1 OR doi ILIKE $1"; + const ORDER: &str = "ORDER BY publication_date DESC NULLS LAST, id DESC"; + + let (total, rows): (i64, Vec) = if let Some(t) = term { + let like = format!("%{t}%"); + let total: i64 = + sqlx::query_scalar(&format!("SELECT count(*) FROM pubs.publication {FILTER}")) + .bind(&like) + .fetch_one(pool) + .await?; + let rows = sqlx::query_as(&format!("{SELECT} {FILTER} {ORDER} LIMIT $2 OFFSET $3")) + .bind(&like) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + (total, rows) + } else { + let total: i64 = sqlx::query_scalar("SELECT count(*) FROM pubs.publication") + .fetch_one(pool) + .await?; + let rows = sqlx::query_as(&format!("{SELECT} {ORDER} LIMIT $1 OFFSET $2")) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + (total, rows) + }; + + Ok(Page { + items: rows.into_iter().map(Into::into).collect(), + total, + page: page.max(1), + page_size: limit, + }) +} diff --git a/rust/crates/du-db/src/variant.rs b/rust/crates/du-db/src/variant.rs new file mode 100644 index 0000000..529ba24 --- /dev/null +++ b/rust/crates/du-db/src/variant.rs @@ -0,0 +1,96 @@ +//! Queries for `core.variant`. Demonstrates the du-db mapping pattern: +//! enum columns are fetched as `::text` and parsed via serde; JSONB columns are +//! read through `sqlx::types::Json` into the du-domain payload structs. + +use crate::{parse_pg_enum, DbError, Page}; +use du_domain::ids::VariantId; +use du_domain::variant::{Aliases, Annotations, Coordinates, Variant}; +use sqlx::types::Json; +use sqlx::PgPool; + +#[derive(sqlx::FromRow)] +struct VariantRow { + id: i64, + canonical_name: String, + mutation_type: String, + naming_status: String, + aliases: Json, + coordinates: Json, + annotations: Json, +} + +impl VariantRow { + fn into_domain(self) -> Result { + Ok(Variant { + id: VariantId(self.id), + canonical_name: self.canonical_name, + mutation_type: parse_pg_enum(&self.mutation_type, "mutation_type")?, + naming_status: parse_pg_enum(&self.naming_status, "naming_status")?, + aliases: self.aliases.0, + coordinates: self.coordinates.0, + annotations: self.annotations.0, + }) + } +} + +const SELECT: &str = "SELECT id, canonical_name, mutation_type::text AS mutation_type, \ + naming_status::text AS naming_status, aliases, coordinates, annotations FROM core.variant"; + +pub async fn get_by_id(pool: &PgPool, id: VariantId) -> Result, DbError> { + let row: Option = sqlx::query_as(&format!("{SELECT} WHERE id = $1")) + .bind(id.0) + .fetch_optional(pool) + .await?; + row.map(VariantRow::into_domain).transpose() +} + +/// Paginated search by canonical name OR any alias in the `common_names`/`rs_ids` +/// JSONB arrays (the public variant browser). `query = None`/empty lists all. +pub async fn search( + pool: &PgPool, + query: Option<&str>, + page: i64, + page_size: i64, +) -> Result, DbError> { + let offset = Page::<()>::offset(page, page_size); + let limit = page_size.clamp(1, 200); + let term = query.map(str::trim).filter(|q| !q.is_empty()); + + // Matches canonical_name or any element of the alias arrays, case-insensitive. + const FILTER: &str = "WHERE canonical_name ILIKE $1 \ + OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(aliases->'common_names') a WHERE a ILIKE $1) \ + OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(aliases->'rs_ids') r WHERE r ILIKE $1)"; + + let (total, rows): (i64, Vec) = if let Some(t) = term { + let like = format!("%{t}%"); + let total: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM core.variant {FILTER}")) + .bind(&like) + .fetch_one(pool) + .await?; + let rows = sqlx::query_as(&format!( + "{SELECT} {FILTER} ORDER BY canonical_name LIMIT $2 OFFSET $3" + )) + .bind(&like) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + (total, rows) + } else { + let total: i64 = sqlx::query_scalar("SELECT count(*) FROM core.variant") + .fetch_one(pool) + .await?; + let rows = sqlx::query_as(&format!("{SELECT} ORDER BY canonical_name LIMIT $1 OFFSET $2")) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + (total, rows) + }; + + let items = rows + .into_iter() + .map(VariantRow::into_domain) + .collect::, _>>()?; + Ok(Page { items, total, page: page.max(1), page_size: limit }) +} diff --git a/rust/crates/du-db/tests/queries.rs b/rust/crates/du-db/tests/queries.rs new file mode 100644 index 0000000..f3c33b1 --- /dev/null +++ b/rust/crates/du-db/tests/queries.rs @@ -0,0 +1,145 @@ +//! Live-DB tests for the du-db query modules. Seeds sentinel rows (prefixed +//! `TESTQ-`), exercises each module, then cleans up so it is re-runnable against +//! a persistent database. Skips (passes) when DATABASE_URL is unset. +//! +//! eval "$(./scripts/test-db.sh up)" && cargo test -p du-db --test queries + +use du_domain::enums::DnaType; +use du_domain::ids::{PublicationId, SampleGuid}; +use serde_json::json; +use sqlx::PgPool; +use uuid::Uuid; + +fn database_url() -> Option { + std::env::var("DATABASE_URL").ok().filter(|s| !s.is_empty()) +} + +async fn cleanup(pool: &PgPool) { + // Order respects FKs: edges -> haplogroups; the rest are independent. + let _ = sqlx::query( + "DELETE FROM tree.haplogroup_relationship r USING tree.haplogroup h \ + WHERE (r.child_haplogroup_id = h.id OR r.parent_haplogroup_id = h.id) AND h.name LIKE 'TESTQ-%'", + ) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM tree.haplogroup WHERE name LIKE 'TESTQ-%'").execute(pool).await; + let _ = sqlx::query("DELETE FROM core.variant WHERE canonical_name LIKE 'TESTQ-%'").execute(pool).await; + let _ = sqlx::query("DELETE FROM pubs.publication WHERE title LIKE 'TESTQ-%'").execute(pool).await; + let _ = sqlx::query("DELETE FROM core.biosample WHERE accession LIKE 'TESTQ-%'").execute(pool).await; +} + +#[tokio::test] +async fn query_modules_against_live_db() { + let Some(url) = database_url() else { + eprintln!("DATABASE_URL unset — skipping live-DB query test"); + return; + }; + let pool = du_db::connect(&url, 4).await.expect("connect"); + du_db::run_migrations(&pool).await.expect("migrate"); + cleanup(&pool).await; // in case a prior run left sentinels behind + + // ── seed ────────────────────────────────────────────────────────────── + let v1: i64 = sqlx::query_scalar( + "INSERT INTO core.variant (canonical_name, mutation_type, aliases, coordinates) \ + VALUES ('TESTQ-M269', 'SNP'::core.mutation_type, $1, $2) RETURNING id", + ) + .bind(json!({"common_names": ["TESTQ-RM269"]})) + .bind(json!({"GRCh38": {"contig": "chrY", "position": 2787319}})) + .fetch_one(&pool) + .await + .expect("insert v1"); + + sqlx::query( + "INSERT INTO core.variant (canonical_name, mutation_type, aliases) \ + VALUES ('TESTQ-L21', 'SNP'::core.mutation_type, $1)", + ) + .bind(json!({"rs_ids": ["rsTESTQ999"]})) + .execute(&pool) + .await + .expect("insert v2"); + + let root: i64 = sqlx::query_scalar( + "INSERT INTO tree.haplogroup (name, haplogroup_type) \ + VALUES ('TESTQ-ROOT', 'Y_DNA'::core.dna_type) RETURNING id", + ) + .fetch_one(&pool) + .await + .expect("insert root"); + let child: i64 = sqlx::query_scalar( + "INSERT INTO tree.haplogroup (name, haplogroup_type) \ + VALUES ('TESTQ-CHILD', 'Y_DNA'::core.dna_type) RETURNING id", + ) + .fetch_one(&pool) + .await + .expect("insert child"); + sqlx::query( + "INSERT INTO tree.haplogroup_relationship (child_haplogroup_id, parent_haplogroup_id) \ + VALUES ($1, $2)", + ) + .bind(child) + .bind(root) + .execute(&pool) + .await + .expect("insert edge"); + + let pub_id: i64 = sqlx::query_scalar( + "INSERT INTO pubs.publication (title, doi) VALUES ('TESTQ-Ancient DNA', '10.testq/1') RETURNING id", + ) + .fetch_one(&pool) + .await + .expect("insert pub"); + + let sample = Uuid::new_v4(); + sqlx::query( + "INSERT INTO core.biosample (sample_guid, source, accession) \ + VALUES ($1, 'EXTERNAL'::core.biosample_source, 'TESTQ-ACC1')", + ) + .bind(sample) + .execute(&pool) + .await + .expect("insert biosample"); + + // ── variant ─────────────────────────────────────────────────────────── + let by_name = du_db::variant::search(&pool, Some("TESTQ-M269"), 1, 25).await.expect("variant search"); + assert_eq!(by_name.total, 1); + assert_eq!(by_name.items[0].canonical_name, "TESTQ-M269"); + assert_eq!(by_name.items[0].coordinates.get(du_domain::ReferenceBuild::GRCh38).unwrap().contig, "chrY"); + + let got = du_db::variant::get_by_id(&pool, by_name.items[0].id).await.expect("get variant"); + assert_eq!(got.unwrap().canonical_name, "TESTQ-M269"); + + // found by rs_id alias (JSONB array search) + let by_rsid = du_db::variant::search(&pool, Some("rsTESTQ999"), 1, 25).await.expect("variant rsid search"); + assert_eq!(by_rsid.total, 1); + assert_eq!(by_rsid.items[0].canonical_name, "TESTQ-L21"); + + // ── haplogroup / tree ─────────────────────────────────────────────────── + let root_hg = du_db::haplogroup::get_by_name(&pool, "TESTQ-ROOT", DnaType::YDna) + .await + .expect("get root") + .expect("root exists"); + let roots = du_db::haplogroup::roots(&pool, DnaType::YDna).await.expect("roots"); + assert!(roots.iter().any(|h| h.name == "TESTQ-ROOT"), "root listed as a root"); + assert!(!roots.iter().any(|h| h.name == "TESTQ-CHILD"), "child is not a root"); + + let kids = du_db::haplogroup::children(&pool, root_hg.id).await.expect("children"); + assert_eq!(kids.len(), 1); + assert_eq!(kids[0].name, "TESTQ-CHILD"); + + // ── publication ────────────────────────────────────────────────────────── + let pubs = du_db::publication::search(&pool, Some("TESTQ"), 1, 25).await.expect("pub search"); + assert!(pubs.items.iter().any(|p| p.title == "TESTQ-Ancient DNA")); + let one = du_db::publication::get_by_id(&pool, PublicationId(pub_id)).await.expect("get pub"); + assert_eq!(one.unwrap().doi.as_deref(), Some("10.testq/1")); + + // ── biosample ───────────────────────────────────────────────────────────── + let bs = du_db::biosample::get_by_guid(&pool, SampleGuid(sample)).await.expect("get biosample"); + assert_eq!(bs.unwrap().accession.as_deref(), Some("TESTQ-ACC1")); + let found = du_db::biosample::find_by_alias_or_accession(&pool, "TESTQ-ACC1").await.expect("find biosample"); + assert_eq!(found.len(), 1); + + // ensure the unused binding is acknowledged + let _ = v1; + + cleanup(&pool).await; +} diff --git a/rust/crates/du-domain/src/biosample.rs b/rust/crates/du-domain/src/biosample.rs new file mode 100644 index 0000000..0e5a227 --- /dev/null +++ b/rust/crates/du-domain/src/biosample.rs @@ -0,0 +1,22 @@ +//! Biosample domain type — the unified sample (standard/citizen/pgp/external/ +//! ancient) discriminated by `source`, with source-specific fields and the +//! AT Protocol reference carried in JSONB (plan §2). + +use crate::enums::BiosampleSource; +use crate::ids::SampleGuid; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Biosample { + pub sample_guid: SampleGuid, + pub source: BiosampleSource, + pub accession: Option, + pub alias: Option, + pub description: Option, + pub center_name: Option, + pub locked: bool, + /// `core.biosample.source_attrs` JSONB (source-specific fields). + pub source_attrs: serde_json::Value, + /// `core.biosample.atproto` JSONB ({uri, cid, repo_did}) or None. + pub atproto: Option, +} diff --git a/rust/crates/du-domain/src/haplogroup.rs b/rust/crates/du-domain/src/haplogroup.rs new file mode 100644 index 0000000..b8876f4 --- /dev/null +++ b/rust/crates/du-domain/src/haplogroup.rs @@ -0,0 +1,19 @@ +//! Haplogroup (phylogenetic tree node) domain type. + +use crate::enums::DnaType; +use crate::ids::HaplogroupId; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Haplogroup { + pub id: HaplogroupId, + pub name: String, + pub haplogroup_type: DnaType, + pub lineage: Option, + pub source: Option, + pub confidence_level: Option, + pub formed_ybp: Option, + pub tmrca_ybp: Option, + /// `tree.haplogroup.provenance` JSONB (multi-source attribution / age detail). + pub provenance: serde_json::Value, +} diff --git a/rust/crates/du-domain/src/lib.rs b/rust/crates/du-domain/src/lib.rs index 3d4d61f..4ea54b6 100644 --- a/rust/crates/du-domain/src/lib.rs +++ b/rust/crates/du-domain/src/lib.rs @@ -5,9 +5,12 @@ //! structs so both `du-db` (persistence) and `du-web` (presentation) share one //! source of truth. +pub mod biosample; pub mod enums; pub mod error; +pub mod haplogroup; pub mod ids; +pub mod publication; pub mod variant; pub use enums::*; diff --git a/rust/crates/du-domain/src/publication.rs b/rust/crates/du-domain/src/publication.rs new file mode 100644 index 0000000..19978bd --- /dev/null +++ b/rust/crates/du-domain/src/publication.rs @@ -0,0 +1,19 @@ +//! Publication domain type (research papers, enriched via OpenAlex). + +use crate::ids::PublicationId; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Publication { + pub id: PublicationId, + pub title: String, + pub doi: Option, + pub pubmed_id: Option, + pub journal: Option, + pub publication_date: Option, + pub authors: Option, + pub abstract_summary: Option, + pub url: Option, + pub cited_by_count: Option, + pub open_access_status: Option, +} From 8e2b06ed641739e803f87d87c331ec1e997f132b Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 07:45:21 -0500 Subject: [PATCH 003/191] feat(rust): public read-only vertical slice (variant browser + Y/MT tree) First user-visible slice through Axum + Askama + HTMX, server-rendered with HATEOAS fragment navigation, verified end-to-end against the live PostGIS: - du-web restructured: AppState (PgPool), AppError -> HTTP mapping, explicit Askama render helper, routes/ module. main() connects+migrates when DATABASE_URL is set, else serves /health only. - Variant browser: /variants page lazy-loads /variants/list fragment (search by name + common_names/rs_ids JSONB aliases, paginated); rows load /variants/detail/:id panel rendering multi-build coordinates. - Tree: /ytree & /mtree pages lazy-load /{y,m}tree/fragment; clicking a node swaps #tree-container and pushes the URL (hx-get + href + hx-push-url) so browser back/forward walks the tree. Graceful empty states + 404s. - du-domain: Display/label() for DnaType/MutationType/NamingStatus (templates). - du-db: re-export PgPool so du-web needn't depend on sqlx; migrations test uses a sentinel variant name to avoid colliding with real data. - Askama templates (base/index/variants/tree) on Bootstrap 5 + htmx 2. Workspace 7/7 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/du-db/src/lib.rs | 4 +- rust/crates/du-db/tests/migrations.rs | 4 +- rust/crates/du-domain/src/enums.rs | 47 ++++++ rust/crates/du-web/src/error.rs | 27 ++++ rust/crates/du-web/src/main.rs | 34 +++-- rust/crates/du-web/src/render.rs | 20 +++ rust/crates/du-web/src/routes.rs | 27 ---- rust/crates/du-web/src/routes/mod.rs | 55 +++++++ rust/crates/du-web/src/routes/tree.rs | 92 ++++++++++++ rust/crates/du-web/src/routes/variants.rs | 138 ++++++++++++++++++ rust/crates/du-web/src/state.rs | 8 + rust/crates/du-web/templates/base.html | 31 ++++ rust/crates/du-web/templates/index.html | 16 ++ .../du-web/templates/tree/fragment.html | 29 ++++ rust/crates/du-web/templates/tree/page.html | 10 ++ .../du-web/templates/variants/browser.html | 26 ++++ .../du-web/templates/variants/detail.html | 40 +++++ .../du-web/templates/variants/list.html | 35 +++++ 18 files changed, 602 insertions(+), 41 deletions(-) create mode 100644 rust/crates/du-web/src/error.rs create mode 100644 rust/crates/du-web/src/render.rs delete mode 100644 rust/crates/du-web/src/routes.rs create mode 100644 rust/crates/du-web/src/routes/mod.rs create mode 100644 rust/crates/du-web/src/routes/tree.rs create mode 100644 rust/crates/du-web/src/routes/variants.rs create mode 100644 rust/crates/du-web/src/state.rs create mode 100644 rust/crates/du-web/templates/base.html create mode 100644 rust/crates/du-web/templates/index.html create mode 100644 rust/crates/du-web/templates/tree/fragment.html create mode 100644 rust/crates/du-web/templates/tree/page.html create mode 100644 rust/crates/du-web/templates/variants/browser.html create mode 100644 rust/crates/du-web/templates/variants/detail.html create mode 100644 rust/crates/du-web/templates/variants/list.html diff --git a/rust/crates/du-db/src/lib.rs b/rust/crates/du-db/src/lib.rs index fd44f8a..47f5333 100644 --- a/rust/crates/du-db/src/lib.rs +++ b/rust/crates/du-db/src/lib.rs @@ -3,7 +3,9 @@ //! Status: scaffold. The pool + error type are wired; query modules //! (`biosample`, `variant`, `haplogroup`, …) land as each subsystem is ported. -use sqlx::postgres::{PgPool, PgPoolOptions}; +/// Re-exported so downstream crates can hold a pool without depending on sqlx. +pub use sqlx::postgres::PgPool; +use sqlx::postgres::PgPoolOptions; use std::time::Duration; use thiserror::Error; diff --git a/rust/crates/du-db/tests/migrations.rs b/rust/crates/du-db/tests/migrations.rs index 669619e..5b27315 100644 --- a/rust/crates/du-db/tests/migrations.rs +++ b/rust/crates/du-db/tests/migrations.rs @@ -91,7 +91,9 @@ async fn migrations_apply_and_variant_jsonb_roundtrips() { "INSERT INTO core.variant (canonical_name, mutation_type, coordinates) VALUES ($1, 'SNP', $2) RETURNING id", ) - .bind("M269") + // Sentinel name so this never collides with seeded/real data (canonical_name + // is unique). + .bind("TESTMIG-M269") .bind(&coords_json) .fetch_one(&pool) .await diff --git a/rust/crates/du-domain/src/enums.rs b/rust/crates/du-domain/src/enums.rs index a53ab8d..c2710c5 100644 --- a/rust/crates/du-domain/src/enums.rs +++ b/rust/crates/du-domain/src/enums.rs @@ -103,6 +103,53 @@ impl ReferenceBuild { } } +impl DnaType { + pub fn label(&self) -> &'static str { + match self { + DnaType::YDna => "Y_DNA", + DnaType::MtDna => "MT_DNA", + } + } +} +impl std::fmt::Display for DnaType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + +impl MutationType { + pub fn label(&self) -> &'static str { + match self { + MutationType::Snp => "SNP", + MutationType::Indel => "INDEL", + MutationType::Str => "STR", + MutationType::Del => "DEL", + MutationType::Ins => "INS", + MutationType::Mnp => "MNP", + } + } +} +impl std::fmt::Display for MutationType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + +impl NamingStatus { + pub fn label(&self) -> &'static str { + match self { + NamingStatus::Unnamed => "UNNAMED", + NamingStatus::PendingReview => "PENDING_REVIEW", + NamingStatus::Named => "NAMED", + } + } +} +impl std::fmt::Display for NamingStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + /// Tree change-set lifecycle (legacy `tree.change_set_status`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] diff --git a/rust/crates/du-web/src/error.rs b/rust/crates/du-web/src/error.rs new file mode 100644 index 0000000..45cfeeb --- /dev/null +++ b/rust/crates/du-web/src/error.rs @@ -0,0 +1,27 @@ +//! Handler error type. Maps data-layer failures to HTTP responses. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +pub enum AppError { + Db(du_db::DbError), + NotFound(String), +} + +impl From for AppError { + fn from(e: du_db::DbError) -> Self { + AppError::Db(e) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + match self { + AppError::Db(e) => { + tracing::error!(error = %e, "database error"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() + } + AppError::NotFound(what) => (StatusCode::NOT_FOUND, format!("not found: {what}")).into_response(), + } + } +} diff --git a/rust/crates/du-web/src/main.rs b/rust/crates/du-web/src/main.rs index f827a2e..9a221da 100644 --- a/rust/crates/du-web/src/main.rs +++ b/rust/crates/du-web/src/main.rs @@ -1,13 +1,16 @@ -//! DecodingUs web binary (Axum). HTML + JSON API + static assets + firehose. +//! DecodingUs web binary (Axum). HTML + (later) JSON API + firehose. //! -//! Scaffold: boots tracing, builds the router, serves `/health` (carried over -//! from the Play app's load-balancer check). Subsystem routers are mounted as -//! they are ported (plan §4, §10). +//! Public vertical slice: home, variant browser, and Y/MT tree navigation, +//! server-rendered with Askama and driven by HTMX fragments (plan §4). -use axum::{routing::get, Router}; use std::net::SocketAddr; +mod error; +mod render; mod routes; +mod state; + +use state::AppState; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -18,7 +21,20 @@ async fn main() -> anyhow::Result<()> { ) .init(); - let app = build_router(); + // Build the app. With a DATABASE_URL we connect, migrate, and serve the full + // site; without one we serve only /health (keeps the binary runnable bare). + let app = match std::env::var("DATABASE_URL").ok().filter(|s| !s.is_empty()) { + Some(url) => { + let pool = du_db::connect(&url, 8).await?; + du_db::run_migrations(&pool).await?; + tracing::info!("connected to database; migrations applied"); + routes::app(AppState { pool }) + } + None => { + tracing::warn!("DATABASE_URL not set — serving /health only"); + routes::health_only() + } + }; let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(9000); let addr = SocketAddr::from(([0, 0, 0, 0], port)); @@ -28,9 +44,3 @@ async fn main() -> anyhow::Result<()> { axum::serve(listener, app).await?; Ok(()) } - -/// Builds the application router. Split out so handler tests can exercise it -/// without binding a socket. -pub fn build_router() -> Router { - Router::new().route("/health", get(routes::health)) -} diff --git a/rust/crates/du-web/src/render.rs b/rust/crates/du-web/src/render.rs new file mode 100644 index 0000000..8104ea6 --- /dev/null +++ b/rust/crates/du-web/src/render.rs @@ -0,0 +1,20 @@ +//! Renders an Askama template into an HTML response. Used instead of the +//! `askama_axum` IntoResponse integration to keep the rendering path explicit +//! and version-independent. + +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; + +pub fn html(t: &T) -> Response { + match t.render() { + Ok(body) => ( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + body, + ) + .into_response(), + Err(e) => { + tracing::error!(error = %e, "template render failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response() + } + } +} diff --git a/rust/crates/du-web/src/routes.rs b/rust/crates/du-web/src/routes.rs deleted file mode 100644 index 994a870..0000000 --- a/rust/crates/du-web/src/routes.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! HTTP handlers. Grows into submodules (public, curator, api, federation) as -//! subsystems are ported. For now: the health check. - -use axum::http::StatusCode; - -/// Load-balancer / container health check. Mirrors the Play `/health` route. -pub async fn health() -> (StatusCode, &'static str) { - (StatusCode::OK, "ok") -} - -#[cfg(test)] -mod tests { - use crate::build_router; - use axum::body::Body; - use axum::http::{Request, StatusCode}; - use tower::ServiceExt; // for `oneshot` - - #[tokio::test] - async fn health_returns_ok() { - let app = build_router(); - let resp = app - .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - } -} diff --git a/rust/crates/du-web/src/routes/mod.rs b/rust/crates/du-web/src/routes/mod.rs new file mode 100644 index 0000000..025b176 --- /dev/null +++ b/rust/crates/du-web/src/routes/mod.rs @@ -0,0 +1,55 @@ +//! Router assembly + top-level pages. + +use crate::render::html; +use crate::state::AppState; +use axum::http::StatusCode; +use axum::response::Response; +use axum::routing::get; +use axum::Router; + +pub mod tree; +pub mod variants; + +/// Full application router (requires a DB-backed AppState). +pub fn app(state: AppState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/", get(index)) + .merge(variants::router()) + .merge(tree::router()) + .with_state(state) +} + +/// Health-only router for environments without a database (and for tests). +pub fn health_only() -> Router { + Router::new().route("/health", get(health)) +} + +async fn health() -> (StatusCode, &'static str) { + (StatusCode::OK, "ok") +} + +#[derive(askama::Template)] +#[template(path = "index.html")] +struct IndexTemplate; + +async fn index() -> Response { + html(&IndexTemplate) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + #[tokio::test] + async fn health_returns_ok() { + let resp = health_only() + .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } +} diff --git a/rust/crates/du-web/src/routes/tree.rs b/rust/crates/du-web/src/routes/tree.rs new file mode 100644 index 0000000..be24d72 --- /dev/null +++ b/rust/crates/du-web/src/routes/tree.rs @@ -0,0 +1,92 @@ +//! Public Y/MT haplogroup tree navigation. `/ytree` and `/mtree` are full pages +//! whose `#tree-container` lazy-loads a fragment; clicking a node swaps the +//! fragment and pushes the URL (HATEOAS navigation, no client routing). + +use crate::error::AppError; +use crate::render::html; +use crate::state::AppState; +use axum::extract::{Query, State}; +use axum::response::Response; +use axum::routing::get; +use axum::Router; +use du_domain::enums::DnaType; +use serde::Deserialize; + +pub fn router() -> Router { + Router::new() + .route("/ytree", get(ytree_page)) + .route("/mtree", get(mtree_page)) + .route("/ytree/fragment", get(ytree_fragment)) + .route("/mtree/fragment", get(mtree_fragment)) +} + +#[derive(Deserialize)] +struct RootQuery { + root: Option, +} + +#[derive(askama::Template)] +#[template(path = "tree/page.html")] +struct TreePageTemplate { + title: &'static str, + base_path: &'static str, + root: Option, +} + +struct NodeView { + name: String, + formed_ybp: Option, +} + +#[derive(askama::Template)] +#[template(path = "tree/fragment.html")] +struct FragmentTemplate { + base_path: &'static str, + current: Option, + nodes: Vec, +} + +async fn ytree_page(Query(q): Query) -> Response { + html(&TreePageTemplate { title: "Y-DNA Tree", base_path: "/ytree", root: q.root }) +} + +async fn mtree_page(Query(q): Query) -> Response { + html(&TreePageTemplate { title: "mtDNA Tree", base_path: "/mtree", root: q.root }) +} + +async fn ytree_fragment(st: State, q: Query) -> Result { + fragment(st, q, DnaType::YDna, "/ytree").await +} + +async fn mtree_fragment(st: State, q: Query) -> Result { + fragment(st, q, DnaType::MtDna, "/mtree").await +} + +async fn fragment( + State(st): State, + Query(q): Query, + dna_type: DnaType, + base_path: &'static str, +) -> Result { + let to_view = |h: du_domain::haplogroup::Haplogroup| NodeView { + name: h.name, + formed_ybp: h.formed_ybp, + }; + + let (current, nodes) = match q.root.as_deref().filter(|s| !s.is_empty()) { + None => (None, du_db::haplogroup::roots(&st.pool, dna_type).await?), + Some(name) => { + let cur = du_db::haplogroup::get_by_name(&st.pool, name, dna_type) + .await? + .ok_or_else(|| AppError::NotFound(format!("haplogroup {name}")))?; + let kids = du_db::haplogroup::children(&st.pool, cur.id).await?; + (Some(cur), kids) + } + }; + + Ok(html(&FragmentTemplate { + base_path, + current: current.map(to_view), + nodes: nodes.into_iter().map(to_view).collect(), + })) +} diff --git a/rust/crates/du-web/src/routes/variants.rs b/rust/crates/du-web/src/routes/variants.rs new file mode 100644 index 0000000..13c64b8 --- /dev/null +++ b/rust/crates/du-web/src/routes/variants.rs @@ -0,0 +1,138 @@ +//! Public variant browser. Demonstrates the HTMX two-panel + fragment pattern: +//! a full page (`/variants`) that lazy-loads a list fragment (`/variants/list`), +//! whose rows load a detail panel fragment (`/variants/detail/:id`). + +use crate::error::AppError; +use crate::render::html; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::response::Response; +use axum::routing::get; +use axum::Router; +use du_db::Page; +use du_domain::ids::VariantId; +use du_domain::variant::Variant; +use serde::Deserialize; + +pub fn router() -> Router { + Router::new() + .route("/variants", get(browser)) + .route("/variants/list", get(list)) + .route("/variants/detail/:id", get(detail)) +} + +#[derive(Deserialize)] +struct ListQuery { + query: Option, + page: Option, + page_size: Option, +} + +/// A flattened variant for the list table (templates stay logic-free). +struct RowView { + id: i64, + name: String, + mutation_type: String, + naming_status: String, + builds: String, +} + +impl RowView { + fn from(v: &Variant) -> Self { + let mut builds: Vec<&str> = v.coordinates.0.keys().map(String::as_str).collect(); + builds.sort_unstable(); + RowView { + id: v.id.0, + name: v.canonical_name.clone(), + mutation_type: v.mutation_type.label().to_string(), + naming_status: v.naming_status.label().to_string(), + builds: builds.join(", "), + } + } +} + +#[derive(askama::Template)] +#[template(path = "variants/browser.html")] +struct BrowserTemplate { + query: String, +} + +#[derive(askama::Template)] +#[template(path = "variants/list.html")] +struct ListTemplate { + query: String, + rows: Vec, + page: i64, + page_size: i64, + total: i64, + total_pages: i64, +} + +struct CoordView { + build: String, + contig: String, + position: i64, + change: Option, +} + +#[derive(askama::Template)] +#[template(path = "variants/detail.html")] +struct DetailTemplate { + name: String, + mutation_type: String, + naming_status: String, + common_names: Vec, + rs_ids: Vec, + coords: Vec, +} + +async fn browser(Query(q): Query) -> Response { + html(&BrowserTemplate { query: q.query.unwrap_or_default() }) +} + +async fn list(State(st): State, Query(q): Query) -> Result { + let page_num = q.page.unwrap_or(1); + let page_size = q.page_size.unwrap_or(25); + let result: Page = + du_db::variant::search(&st.pool, q.query.as_deref(), page_num, page_size).await?; + let rows = result.items.iter().map(RowView::from).collect(); + Ok(html(&ListTemplate { + query: q.query.unwrap_or_default(), + rows, + page: result.page, + page_size: result.page_size, + total: result.total, + total_pages: result.total_pages(), + })) +} + +async fn detail(State(st): State, Path(id): Path) -> Result { + let v = du_db::variant::get_by_id(&st.pool, VariantId(id)) + .await? + .ok_or_else(|| AppError::NotFound(format!("variant {id}")))?; + + let mut coords: Vec = v + .coordinates + .0 + .iter() + .map(|(build, c)| CoordView { + build: build.clone(), + contig: c.contig.clone(), + position: c.position, + change: match (&c.reference_allele, &c.alternate_allele) { + (Some(r), Some(a)) => Some(format!("{r}>{a}")), + _ => None, + }, + }) + .collect(); + coords.sort_by(|a, b| a.build.cmp(&b.build)); + + Ok(html(&DetailTemplate { + name: v.canonical_name, + mutation_type: v.mutation_type.label().to_string(), + naming_status: v.naming_status.label().to_string(), + common_names: v.aliases.common_names, + rs_ids: v.aliases.rs_ids, + coords, + })) +} diff --git a/rust/crates/du-web/src/state.rs b/rust/crates/du-web/src/state.rs new file mode 100644 index 0000000..546c93c --- /dev/null +++ b/rust/crates/du-web/src/state.rs @@ -0,0 +1,8 @@ +//! Shared application state injected into handlers via `State`. + +use du_db::PgPool; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, +} diff --git a/rust/crates/du-web/templates/base.html b/rust/crates/du-web/templates/base.html new file mode 100644 index 0000000..c6c9e26 --- /dev/null +++ b/rust/crates/du-web/templates/base.html @@ -0,0 +1,31 @@ + + + + + + {% block title %}Decoding Us{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/rust/crates/du-web/templates/index.html b/rust/crates/du-web/templates/index.html new file mode 100644 index 0000000..f0a1bc3 --- /dev/null +++ b/rust/crates/du-web/templates/index.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Decoding Us — genetic genealogy & population research{% endblock %} +{% block content %} +
+

Decoding Us

+

+ A collaborative platform for genetic genealogy and population research — + Y/mtDNA haplogroup trees, a public variant browser, and privacy-preserving + relative discovery. +

+ +
+{% endblock %} diff --git a/rust/crates/du-web/templates/tree/fragment.html b/rust/crates/du-web/templates/tree/fragment.html new file mode 100644 index 0000000..f4ceb9d --- /dev/null +++ b/rust/crates/du-web/templates/tree/fragment.html @@ -0,0 +1,29 @@ +{# Fragment: one tree level. Clicking a node swaps this container and pushes the + URL so browser back/forward navigates the tree (HATEOAS). #} +{% if let Some(cur) = current %} + +

+ {{ cur.name }} + {% if let Some(ybp) = cur.formed_ybp %}· formed ~{{ ybp }} ybp{% endif %} +

+{% else %} +

Root lineages

+{% endif %} + +
+ {% for n in nodes %} + + {{ n.name }} + {% if let Some(ybp) = n.formed_ybp %}~{{ ybp }} ybp{% endif %} + + {% else %} +
No child haplogroups.
+ {% endfor %} +
diff --git a/rust/crates/du-web/templates/tree/page.html b/rust/crates/du-web/templates/tree/page.html new file mode 100644 index 0000000..b5d78af --- /dev/null +++ b/rust/crates/du-web/templates/tree/page.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}{{ title }} — Decoding Us{% endblock %} +{% block content %} +

{{ title }}

+
+
Loading tree… +
+{% endblock %} diff --git a/rust/crates/du-web/templates/variants/browser.html b/rust/crates/du-web/templates/variants/browser.html new file mode 100644 index 0000000..dc36d38 --- /dev/null +++ b/rust/crates/du-web/templates/variants/browser.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Variant Browser — Decoding Us{% endblock %} +{% block content %} +

Variant Browser

+
+
+ + +
+
+
+
+
+

Select a variant to see details.

+
+
+
+{% endblock %} diff --git a/rust/crates/du-web/templates/variants/detail.html b/rust/crates/du-web/templates/variants/detail.html new file mode 100644 index 0000000..8a19ace --- /dev/null +++ b/rust/crates/du-web/templates/variants/detail.html @@ -0,0 +1,40 @@ +{# Fragment: variant detail card. Target of #detail-panel. #} +
+
+ {{ name }} + {{ mutation_type }} +
+
+

Naming status: {{ naming_status }}

+ + {% if !common_names.is_empty() %} +

Also known as: + {% for n in common_names %}{{ n }} {% endfor %} +

+ {% endif %} + {% if !rs_ids.is_empty() %} +

rs IDs: + {% for r in rs_ids %}{{ r }} {% endfor %} +

+ {% endif %} + +
Coordinates
+ {% if coords.is_empty() %} +

No mapped coordinates.

+ {% else %} + + + + {% for c in coords %} + + + + + + + {% endfor %} + +
BuildContigPositionChange
{{ c.build }}{{ c.contig }}{{ c.position }}{% if let Some(ch) = c.change %}{{ ch }}{% else %}—{% endif %}
+ {% endif %} +
+
diff --git a/rust/crates/du-web/templates/variants/list.html b/rust/crates/du-web/templates/variants/list.html new file mode 100644 index 0000000..fd42f17 --- /dev/null +++ b/rust/crates/du-web/templates/variants/list.html @@ -0,0 +1,35 @@ +{# Fragment: the variant results table + pager. Target of #variants-table. #} + + + + + + {% for r in rows %} + + + + + + + {% else %} + + {% endfor %} + +
NameTypeStatusBuilds
{{ r.name }}{{ r.mutation_type }}{{ r.naming_status }}{{ r.builds }}
No variants match.
+ +{% if total_pages > 1 %} + +{% else %} +{{ total }} total +{% endif %} From faf04e249bc39d3cd7d9c41e28138a33cc7b3467 Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 08:09:23 -0500 Subject: [PATCH 004/191] feat(rust): harden public slice to plan spec (assets, i18n, HX negotiation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the read-only slice up to plan §4: Vendored assets: bootstrap 5.3.3 (css + bundle js) and htmx 2.0.4 under crates/du-web/assets/vendor, served via tower-http ServeDir at /assets (DU_ASSETS_DIR env, compile-time crate-dir fallback). Custom main.css replaces inline styles. CDN links removed; Dockerfile copies assets + sets DU_ASSETS_DIR. i18n (en/es/fr): Play-style key=value catalogs embedded via include_str; Lang+T translator with en/key fallback; Locale extractor resolves lang from the `lang` cookie then Accept-Language (default en); navbar language switcher with percent-encoded `next`; GET /language/:lang sets the cookie + redirects with an open-redirect guard. Templates fully localized. Unit tests assert the fallback chain and that es/fr cover every English key. HX-Request unification: HxRequest extractor (htmx + history-restore + target). Tree collapses to one handler per lineage (/ytree, /mtree) — full page embeds the current level inline; an HTMX swap of #tree-container returns just the fragment with a server-driven HX-Push-Url; history-restore and boosted navigations get the full page (target-aware negotiation). HxHeaders builder for HX-* response headers. Variant browser embeds its first results page inline (no load round-trip). Verified live against the container PostGIS; workspace 9/9 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/Cargo.lock | 1 + rust/Cargo.toml | 3 + rust/Dockerfile | 6 +- rust/crates/du-web/Cargo.toml | 1 + rust/crates/du-web/assets/main.css | 4 + .../assets/vendor/bootstrap.bundle.min.js | 7 + .../du-web/assets/vendor/bootstrap.min.css | 6 + rust/crates/du-web/assets/vendor/htmx.min.js | 1 + rust/crates/du-web/src/htmx.rs | 110 ++++++++++ rust/crates/du-web/src/i18n.rs | 188 ++++++++++++++++++ rust/crates/du-web/src/main.rs | 2 + rust/crates/du-web/src/routes/mod.rs | 50 ++++- rust/crates/du-web/src/routes/tree.rs | 95 ++++++--- rust/crates/du-web/src/routes/variants.rs | 82 +++++--- rust/crates/du-web/templates/base.html | 38 ++-- rust/crates/du-web/templates/index.html | 14 +- .../du-web/templates/tree/fragment.html | 19 +- rust/crates/du-web/templates/tree/page.html | 9 +- .../du-web/templates/variants/browser.html | 16 +- .../du-web/templates/variants/detail.html | 17 +- .../du-web/templates/variants/list.html | 35 ++-- rust/locales/en.txt | 48 +++++ rust/locales/es.txt | 48 +++++ rust/locales/fr.txt | 48 +++++ 24 files changed, 716 insertions(+), 132 deletions(-) create mode 100644 rust/crates/du-web/assets/main.css create mode 100644 rust/crates/du-web/assets/vendor/bootstrap.bundle.min.js create mode 100644 rust/crates/du-web/assets/vendor/bootstrap.min.css create mode 100644 rust/crates/du-web/assets/vendor/htmx.min.js create mode 100644 rust/crates/du-web/src/htmx.rs create mode 100644 rust/crates/du-web/src/i18n.rs create mode 100644 rust/locales/en.txt create mode 100644 rust/locales/es.txt create mode 100644 rust/locales/fr.txt diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b9a4033..958913b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -587,6 +587,7 @@ dependencies = [ "axum", "du-db", "du-domain", + "percent-encoding", "serde", "serde_json", "tokio", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 7612440..a7200e7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -56,6 +56,9 @@ rust_decimal = "1" # HTTP client (external APIs) reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +# URL/query encoding (language-switcher `next` param) +percent-encoding = "2" + # Errors / logging / config thiserror = "2" anyhow = "1" diff --git a/rust/Dockerfile b/rust/Dockerfile index 75d9f58..3791804 100644 --- a/rust/Dockerfile +++ b/rust/Dockerfile @@ -30,13 +30,15 @@ WORKDIR /app COPY --from=builder /build/target/release/decodingus /usr/local/bin/decodingus COPY --from=builder /build/target/release/decodingus-jobs /usr/local/bin/decodingus-jobs COPY --from=builder /build/target/release/decodingus-migrate /usr/local/bin/decodingus-migrate -# Static assets + migrations shipped alongside the binary. -COPY --chown=decodingus:decodingus assets ./assets +# Vendored static assets + migrations shipped alongside the binary. (Askama +# templates and locale catalogs are embedded into the binary at compile time.) +COPY --chown=decodingus:decodingus crates/du-web/assets ./assets COPY --chown=decodingus:decodingus migrations ./migrations USER decodingus EXPOSE 9000 ENV PORT=9000 +ENV DU_ASSETS_DIR=/app/assets HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ CMD curl -fsS http://localhost:9000/health || exit 1 diff --git a/rust/crates/du-web/Cargo.toml b/rust/crates/du-web/Cargo.toml index 8184fc9..35bf30d 100644 --- a/rust/crates/du-web/Cargo.toml +++ b/rust/crates/du-web/Cargo.toml @@ -18,6 +18,7 @@ tokio = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } askama = { workspace = true } +percent-encoding = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } diff --git a/rust/crates/du-web/assets/main.css b/rust/crates/du-web/assets/main.css new file mode 100644 index 0000000..571513e --- /dev/null +++ b/rust/crates/du-web/assets/main.css @@ -0,0 +1,4 @@ +/* DecodingUs custom styles (vendored bootstrap handles the rest). */ +body { padding-top: 4.5rem; } +.detail-empty { color: var(--bs-secondary-color); } +.tree-node { cursor: pointer; } diff --git a/rust/crates/du-web/assets/vendor/bootstrap.bundle.min.js b/rust/crates/du-web/assets/vendor/bootstrap.bundle.min.js new file mode 100644 index 0000000..04e9185 --- /dev/null +++ b/rust/crates/du-web/assets/vendor/bootstrap.bundle.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==I(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=P(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function P(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function j(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function M(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const F={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${M(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${M(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=j(t.dataset[n])}return e},getDataAttribute:(t,e)=>j(t.getAttribute(`data-bs-${M(e)}`))};class H{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?F.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?F.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends H{constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),N.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map((t=>n(t))).join(","):null},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;N.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))},q=".bs.alert",V=`close${q}`,K=`closed${q}`;class Q extends W{static get NAME(){return"alert"}close(){if(N.trigger(this._element,V).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),N.trigger(this._element,K),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=Q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(Q,"close"),m(Q);const X='[data-bs-toggle="button"]';class Y extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=Y.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}N.on(document,"click.bs.button.data-api",X,(t=>{t.preventDefault();const e=t.target.closest(X);Y.getOrCreateInstance(e).toggle()})),m(Y);const U=".bs.swipe",G=`touchstart${U}`,J=`touchmove${U}`,Z=`touchend${U}`,tt=`pointerdown${U}`,et=`pointerup${U}`,it={endCallback:null,leftCallback:null,rightCallback:null},nt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class st extends H{constructor(t,e){super(),this._element=t,t&&st.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return it}static get DefaultType(){return nt}static get NAME(){return"swipe"}dispose(){N.off(this._element,U)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(N.on(this._element,tt,(t=>this._start(t))),N.on(this._element,et,(t=>this._end(t))),this._element.classList.add("pointer-event")):(N.on(this._element,G,(t=>this._start(t))),N.on(this._element,J,(t=>this._move(t))),N.on(this._element,Z,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const ot=".bs.carousel",rt=".data-api",at="next",lt="prev",ct="left",ht="right",dt=`slide${ot}`,ut=`slid${ot}`,ft=`keydown${ot}`,pt=`mouseenter${ot}`,mt=`mouseleave${ot}`,gt=`dragstart${ot}`,_t=`load${ot}${rt}`,bt=`click${ot}${rt}`,vt="carousel",yt="active",wt=".active",At=".carousel-item",Et=wt+At,Tt={ArrowLeft:ht,ArrowRight:ct},Ct={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class xt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===vt&&this.cycle()}static get Default(){return Ct}static get DefaultType(){return Ot}static get NAME(){return"carousel"}next(){this._slide(at)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(lt)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?N.one(this._element,ut,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void N.one(this._element,ut,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?at:lt;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&N.on(this._element,ft,(t=>this._keydown(t))),"hover"===this._config.pause&&(N.on(this._element,pt,(()=>this.pause())),N.on(this._element,mt,(()=>this._maybeEnableCycle()))),this._config.touch&&st.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))N.on(t,gt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ct)),rightCallback:()=>this._slide(this._directionToOrder(ht)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new st(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=Tt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(wt,this._indicatorsElement);e.classList.remove(yt),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(yt),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===at,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>N.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(dt).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(yt),i.classList.remove(yt,c,l),this._isSliding=!1,r(ut)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(Et,this._element)}_getItems(){return z.find(At,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ct?lt:at:t===ct?at:lt}_orderToDirection(t){return p()?t===lt?ct:ht:t===lt?ht:ct}static jQueryInterface(t){return this.each((function(){const e=xt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}N.on(document,bt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(vt))return;t.preventDefault();const i=xt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===F.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),N.on(window,_t,(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)xt.getOrCreateInstance(e)})),m(xt);const kt=".bs.collapse",Lt=`show${kt}`,St=`shown${kt}`,Dt=`hide${kt}`,$t=`hidden${kt}`,It=`click${kt}.data-api`,Nt="show",Pt="collapse",jt="collapsing",Mt=`:scope .${Pt} .${Pt}`,Ft='[data-bs-toggle="collapse"]',Ht={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Bt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(Ft);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Ht}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Bt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(N.trigger(this._element,Lt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Pt),this._element.classList.add(jt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt,Nt),this._element.style[e]="",N.trigger(this._element,St)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(N.trigger(this._element,Dt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(jt),this._element.classList.remove(Pt,Nt);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt),N.trigger(this._element,$t)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(Nt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Ft);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(Mt,this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Bt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}N.on(document,It,Ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))Bt.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(Bt);var zt="top",Rt="bottom",qt="right",Vt="left",Kt="auto",Qt=[zt,Rt,qt,Vt],Xt="start",Yt="end",Ut="clippingParents",Gt="viewport",Jt="popper",Zt="reference",te=Qt.reduce((function(t,e){return t.concat([e+"-"+Xt,e+"-"+Yt])}),[]),ee=[].concat(Qt,[Kt]).reduce((function(t,e){return t.concat([e,e+"-"+Xt,e+"-"+Yt])}),[]),ie="beforeRead",ne="read",se="afterRead",oe="beforeMain",re="main",ae="afterMain",le="beforeWrite",ce="write",he="afterWrite",de=[ie,ne,se,oe,re,ae,le,ce,he];function ue(t){return t?(t.nodeName||"").toLowerCase():null}function fe(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function pe(t){return t instanceof fe(t).Element||t instanceof Element}function me(t){return t instanceof fe(t).HTMLElement||t instanceof HTMLElement}function ge(t){return"undefined"!=typeof ShadowRoot&&(t instanceof fe(t).ShadowRoot||t instanceof ShadowRoot)}const _e={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];me(s)&&ue(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});me(n)&&ue(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function be(t){return t.split("-")[0]}var ve=Math.max,ye=Math.min,we=Math.round;function Ae(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Ee(){return!/^((?!chrome|android).)*safari/i.test(Ae())}function Te(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&me(t)&&(s=t.offsetWidth>0&&we(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&we(n.height)/t.offsetHeight||1);var r=(pe(t)?fe(t):window).visualViewport,a=!Ee()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Ce(t){var e=Te(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Oe(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&ge(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function xe(t){return fe(t).getComputedStyle(t)}function ke(t){return["table","td","th"].indexOf(ue(t))>=0}function Le(t){return((pe(t)?t.ownerDocument:t.document)||window.document).documentElement}function Se(t){return"html"===ue(t)?t:t.assignedSlot||t.parentNode||(ge(t)?t.host:null)||Le(t)}function De(t){return me(t)&&"fixed"!==xe(t).position?t.offsetParent:null}function $e(t){for(var e=fe(t),i=De(t);i&&ke(i)&&"static"===xe(i).position;)i=De(i);return i&&("html"===ue(i)||"body"===ue(i)&&"static"===xe(i).position)?e:i||function(t){var e=/firefox/i.test(Ae());if(/Trident/i.test(Ae())&&me(t)&&"fixed"===xe(t).position)return null;var i=Se(t);for(ge(i)&&(i=i.host);me(i)&&["html","body"].indexOf(ue(i))<0;){var n=xe(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Ie(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Ne(t,e,i){return ve(t,ye(e,i))}function Pe(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function je(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const Me={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=be(i.placement),l=Ie(a),c=[Vt,qt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return Pe("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:je(t,Qt))}(s.padding,i),d=Ce(o),u="y"===l?zt:Vt,f="y"===l?Rt:qt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=$e(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=Ne(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Oe(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Fe(t){return t.split("-")[1]}var He={top:"auto",right:"auto",bottom:"auto",left:"auto"};function We(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=Vt,y=zt,w=window;if(c){var A=$e(i),E="clientHeight",T="clientWidth";A===fe(i)&&"static"!==xe(A=Le(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===zt||(s===Vt||s===qt)&&o===Yt)&&(y=Rt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==Vt&&(s!==zt&&s!==Rt||o!==Yt)||(v=qt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&He),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:we(i*s)/s||0,y:we(n*s)/s||0}}({x:f,y:m},fe(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const Be={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:be(e.placement),variation:Fe(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,We(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,We(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var ze={passive:!0};const Re={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=fe(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,ze)})),a&&l.addEventListener("resize",i.update,ze),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,ze)})),a&&l.removeEventListener("resize",i.update,ze)}},data:{}};var qe={left:"right",right:"left",bottom:"top",top:"bottom"};function Ve(t){return t.replace(/left|right|bottom|top/g,(function(t){return qe[t]}))}var Ke={start:"end",end:"start"};function Qe(t){return t.replace(/start|end/g,(function(t){return Ke[t]}))}function Xe(t){var e=fe(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ye(t){return Te(Le(t)).left+Xe(t).scrollLeft}function Ue(t){var e=xe(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ge(t){return["html","body","#document"].indexOf(ue(t))>=0?t.ownerDocument.body:me(t)&&Ue(t)?t:Ge(Se(t))}function Je(t,e){var i;void 0===e&&(e=[]);var n=Ge(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=fe(n),r=s?[o].concat(o.visualViewport||[],Ue(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Je(Se(r)))}function Ze(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function ti(t,e,i){return e===Gt?Ze(function(t,e){var i=fe(t),n=Le(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Ee();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Ye(t),y:l}}(t,i)):pe(e)?function(t,e){var i=Te(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ze(function(t){var e,i=Le(t),n=Xe(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ve(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ve(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ye(t),l=-n.scrollTop;return"rtl"===xe(s||i).direction&&(a+=ve(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Le(t)))}function ei(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?be(s):null,r=s?Fe(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case zt:e={x:a,y:i.y-n.height};break;case Rt:e={x:a,y:i.y+i.height};break;case qt:e={x:i.x+i.width,y:l};break;case Vt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Ie(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case Xt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Yt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ii(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Ut:a,c=i.rootBoundary,h=void 0===c?Gt:c,d=i.elementContext,u=void 0===d?Jt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=Pe("number"!=typeof g?g:je(g,Qt)),b=u===Jt?Zt:Jt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Je(Se(t)),i=["absolute","fixed"].indexOf(xe(t).position)>=0&&me(t)?$e(t):t;return pe(i)?e.filter((function(t){return pe(t)&&Oe(t,i)&&"body"!==ue(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=ti(t,i,n);return e.top=ve(s.top,e.top),e.right=ye(s.right,e.right),e.bottom=ye(s.bottom,e.bottom),e.left=ve(s.left,e.left),e}),ti(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(pe(y)?y:y.contextElement||Le(t.elements.popper),l,h,r),A=Te(t.elements.reference),E=ei({reference:A,element:v,strategy:"absolute",placement:s}),T=Ze(Object.assign({},v,E)),C=u===Jt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Jt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[qt,Rt].indexOf(t)>=0?1:-1,i=[zt,Rt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function ni(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?ee:l,h=Fe(n),d=h?a?te:te.filter((function(t){return Fe(t)===h})):Qt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ii(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[be(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const si={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=be(g),b=l||(_!==g&&p?function(t){if(be(t)===Kt)return[];var e=Ve(t);return[Qe(t),e,Qe(e)]}(g):[Ve(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(be(i)===Kt?ni(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=ii(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),$=L?k?qt:Vt:k?Rt:zt;y[S]>w[S]&&($=Ve($));var I=Ve($),N=[];if(o&&N.push(D[x]<=0),a&&N.push(D[$]<=0,D[I]<=0),N.every((function(t){return t}))){T=O,E=!1;break}A.set(O,N)}if(E)for(var P=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},j=p?3:1;j>0&&"break"!==P(j);j--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function oi(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ri(t){return[zt,qt,Rt,Vt].some((function(e){return t[e]>=0}))}const ai={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ii(e,{elementContext:"reference"}),a=ii(e,{altBoundary:!0}),l=oi(r,n),c=oi(a,s,o),h=ri(l),d=ri(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},li={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=ee.reduce((function(t,i){return t[i]=function(t,e,i){var n=be(t),s=[Vt,zt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[Vt,qt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},ci={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=ei({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},hi={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ii(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=be(e.placement),b=Fe(e.placement),v=!b,y=Ie(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?zt:Vt,D="y"===y?Rt:qt,$="y"===y?"height":"width",I=A[y],N=I+g[S],P=I-g[D],j=f?-T[$]/2:0,M=b===Xt?E[$]:T[$],F=b===Xt?-T[$]:-E[$],H=e.elements.arrow,W=f&&H?Ce(H):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=Ne(0,E[$],W[$]),V=v?E[$]/2-j-q-z-O.mainAxis:M-q-z-O.mainAxis,K=v?-E[$]/2+j+q+R+O.mainAxis:F+q+R+O.mainAxis,Q=e.elements.arrow&&$e(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=I+K-Y,G=Ne(f?ye(N,I+V-Y-X):N,I,f?ve(P,U):P);A[y]=G,k[y]=G-I}if(a){var J,Z="x"===y?zt:Vt,tt="x"===y?Rt:qt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[zt,Vt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=Ne(t,e,i);return n>i?i:n}(at,et,lt):Ne(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function di(t,e,i){void 0===i&&(i=!1);var n,s,o=me(e),r=me(e)&&function(t){var e=t.getBoundingClientRect(),i=we(e.width)/t.offsetWidth||1,n=we(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=Le(e),l=Te(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==ue(e)||Ue(a))&&(c=(n=e)!==fe(n)&&me(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:Xe(n)),me(e)?((h=Te(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Ye(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function ui(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var fi={placement:"bottom",modifiers:[],strategy:"absolute"};function pi(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(F.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Ti,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ni);for(const i of e){const e=qi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Ei,Ti].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Ii)?this:z.prev(this,Ii)[0]||z.next(this,Ii)[0]||z.findOne(Ii,t.delegateTarget.parentNode),o=qi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}N.on(document,Si,Ii,qi.dataApiKeydownHandler),N.on(document,Si,Pi,qi.dataApiKeydownHandler),N.on(document,Li,qi.clearMenus),N.on(document,Di,qi.clearMenus),N.on(document,Li,Ii,(function(t){t.preventDefault(),qi.getOrCreateInstance(this).toggle()})),m(qi);const Vi="backdrop",Ki="show",Qi=`mousedown.bs.${Vi}`,Xi={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Yi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Ui extends H{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return Xi}static get DefaultType(){return Yi}static get NAME(){return Vi}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(Ki),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(Ki),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(N.off(this._element,Qi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),N.on(t,Qi,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const Gi=".bs.focustrap",Ji=`focusin${Gi}`,Zi=`keydown.tab${Gi}`,tn="backward",en={autofocus:!0,trapElement:null},nn={autofocus:"boolean",trapElement:"element"};class sn extends H{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return en}static get DefaultType(){return nn}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),N.off(document,Gi),N.on(document,Ji,(t=>this._handleFocusin(t))),N.on(document,Zi,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,N.off(document,Gi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===tn?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?tn:"forward")}}const on=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",rn=".sticky-top",an="padding-right",ln="margin-right";class cn{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,an,(e=>e+t)),this._setElementAttributes(on,an,(e=>e+t)),this._setElementAttributes(rn,ln,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,an),this._resetElementAttributes(on,an),this._resetElementAttributes(rn,ln)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&F.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=F.getDataAttribute(t,e);null!==i?(F.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const hn=".bs.modal",dn=`hide${hn}`,un=`hidePrevented${hn}`,fn=`hidden${hn}`,pn=`show${hn}`,mn=`shown${hn}`,gn=`resize${hn}`,_n=`click.dismiss${hn}`,bn=`mousedown.dismiss${hn}`,vn=`keydown.dismiss${hn}`,yn=`click${hn}.data-api`,wn="modal-open",An="show",En="modal-static",Tn={backdrop:!0,focus:!0,keyboard:!0},Cn={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class On extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new cn,this._addEventListeners()}static get Default(){return Tn}static get DefaultType(){return Cn}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||N.trigger(this._element,pn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(wn),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(N.trigger(this._element,dn).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(An),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){N.off(window,hn),N.off(this._dialog,hn),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Ui({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(An),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,N.trigger(this._element,mn,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){N.on(this._element,vn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),N.on(window,gn,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),N.on(this._element,bn,(t=>{N.one(this._element,_n,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(wn),this._resetAdjustments(),this._scrollBar.reset(),N.trigger(this._element,fn)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(N.trigger(this._element,un).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(En)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(En),this._queueCallback((()=>{this._element.classList.remove(En),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=On.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}N.on(document,yn,'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),N.one(e,pn,(t=>{t.defaultPrevented||N.one(e,fn,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&On.getInstance(i).hide(),On.getOrCreateInstance(e).toggle(this)})),R(On),m(On);const xn=".bs.offcanvas",kn=".data-api",Ln=`load${xn}${kn}`,Sn="show",Dn="showing",$n="hiding",In=".offcanvas.show",Nn=`show${xn}`,Pn=`shown${xn}`,jn=`hide${xn}`,Mn=`hidePrevented${xn}`,Fn=`hidden${xn}`,Hn=`resize${xn}`,Wn=`click${xn}${kn}`,Bn=`keydown.dismiss${xn}`,zn={backdrop:!0,keyboard:!0,scroll:!1},Rn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class qn extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return zn}static get DefaultType(){return Rn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||N.trigger(this._element,Nn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new cn).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Dn),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Sn),this._element.classList.remove(Dn),N.trigger(this._element,Pn,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(N.trigger(this._element,jn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add($n),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Sn,$n),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new cn).reset(),N.trigger(this._element,Fn)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new Ui({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():N.trigger(this._element,Mn)}:null})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_addEventListeners(){N.on(this._element,Bn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():N.trigger(this._element,Mn))}))}static jQueryInterface(t){return this.each((function(){const e=qn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}N.on(document,Wn,'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;N.one(e,Fn,(()=>{a(this)&&this.focus()}));const i=z.findOne(In);i&&i!==e&&qn.getInstance(i).hide(),qn.getOrCreateInstance(e).toggle(this)})),N.on(window,Ln,(()=>{for(const t of z.find(In))qn.getOrCreateInstance(t).show()})),N.on(window,Hn,(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&qn.getOrCreateInstance(t).hide()})),R(qn),m(qn);const Vn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Kn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Qn=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Xn=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Kn.has(i)||Boolean(Qn.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Yn={allowList:Vn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Un={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Gn={entry:"(string|element|function|null)",selector:"(string|element)"};class Jn extends H{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Yn}static get DefaultType(){return Un}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Gn)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Xn(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Zn=new Set(["sanitize","allowList","sanitizeFn"]),ts="fade",es="show",is=".modal",ns="hide.bs.modal",ss="hover",os="focus",rs={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},as={allowList:Vn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},ls={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class cs extends W{constructor(t,e){if(void 0===vi)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return as}static get DefaultType(){return ls}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),N.off(this._element.closest(is),ns,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=N.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),N.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.on(t,"mouseover",h);this._queueCallback((()=>{N.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!N.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger[os]=!1,this._activeTrigger[ss]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),N.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(ts,es),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(ts),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Jn({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(ts)}_isShown(){return this.tip&&this.tip.classList.contains(es)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=rs[e.toUpperCase()];return bi(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)N.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===ss?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===ss?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");N.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?os:ss]=!0,e._enter()})),N.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?os:ss]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},N.on(this._element.closest(is),ns,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=F.getDataAttributes(this._element);for(const t of Object.keys(e))Zn.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=cs.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(cs);const hs={...cs.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},ds={...cs.DefaultType,content:"(null|string|element|function)"};class us extends cs{static get Default(){return hs}static get DefaultType(){return ds}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=us.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(us);const fs=".bs.scrollspy",ps=`activate${fs}`,ms=`click${fs}`,gs=`load${fs}.data-api`,_s="active",bs="[href]",vs=".nav-link",ys=`${vs}, .nav-item > ${vs}, .list-group-item`,ws={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},As={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Es extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ws}static get DefaultType(){return As}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(N.off(this._config.target,ms),N.on(this._config.target,ms,bs,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(bs,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(_s),this._activateParents(t),N.trigger(this._element,ps,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(_s);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,ys))t.classList.add(_s)}_clearActiveClass(t){t.classList.remove(_s);const e=z.find(`${bs}.${_s}`,t);for(const t of e)t.classList.remove(_s)}static jQueryInterface(t){return this.each((function(){const e=Es.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(window,gs,(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))Es.getOrCreateInstance(t)})),m(Es);const Ts=".bs.tab",Cs=`hide${Ts}`,Os=`hidden${Ts}`,xs=`show${Ts}`,ks=`shown${Ts}`,Ls=`click${Ts}`,Ss=`keydown${Ts}`,Ds=`load${Ts}`,$s="ArrowLeft",Is="ArrowRight",Ns="ArrowUp",Ps="ArrowDown",js="Home",Ms="End",Fs="active",Hs="fade",Ws="show",Bs=".dropdown-toggle",zs=`:not(${Bs})`,Rs='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',qs=`.nav-link${zs}, .list-group-item${zs}, [role="tab"]${zs}, ${Rs}`,Vs=`.${Fs}[data-bs-toggle="tab"], .${Fs}[data-bs-toggle="pill"], .${Fs}[data-bs-toggle="list"]`;class Ks extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),N.on(this._element,Ss,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?N.trigger(e,Cs,{relatedTarget:t}):null;N.trigger(t,xs,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Fs),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),N.trigger(t,ks,{relatedTarget:e})):t.classList.add(Ws)}),t,t.classList.contains(Hs)))}_deactivate(t,e){t&&(t.classList.remove(Fs),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),N.trigger(t,Os,{relatedTarget:e})):t.classList.remove(Ws)}),t,t.classList.contains(Hs)))}_keydown(t){if(![$s,Is,Ns,Ps,js,Ms].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!l(t)));let i;if([js,Ms].includes(t.key))i=e[t.key===js?0:e.length-1];else{const n=[Is,Ps].includes(t.key);i=b(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),Ks.getOrCreateInstance(i).show())}_getChildren(){return z.find(qs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(Bs,Fs),n(".dropdown-menu",Ws),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Fs)}_getInnerElement(t){return t.matches(qs)?t:z.findOne(qs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=Ks.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(document,Ls,Rs,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||Ks.getOrCreateInstance(this).show()})),N.on(window,Ds,(()=>{for(const t of z.find(Vs))Ks.getOrCreateInstance(t)})),m(Ks);const Qs=".bs.toast",Xs=`mouseover${Qs}`,Ys=`mouseout${Qs}`,Us=`focusin${Qs}`,Gs=`focusout${Qs}`,Js=`hide${Qs}`,Zs=`hidden${Qs}`,to=`show${Qs}`,eo=`shown${Qs}`,io="hide",no="show",so="showing",oo={animation:"boolean",autohide:"boolean",delay:"number"},ro={animation:!0,autohide:!0,delay:5e3};class ao extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return ro}static get DefaultType(){return oo}static get NAME(){return"toast"}show(){N.trigger(this._element,to).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(io),d(this._element),this._element.classList.add(no,so),this._queueCallback((()=>{this._element.classList.remove(so),N.trigger(this._element,eo),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(N.trigger(this._element,Js).defaultPrevented||(this._element.classList.add(so),this._queueCallback((()=>{this._element.classList.add(io),this._element.classList.remove(so,no),N.trigger(this._element,Zs)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(no),super.dispose()}isShown(){return this._element.classList.contains(no)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){N.on(this._element,Xs,(t=>this._onInteraction(t,!0))),N.on(this._element,Ys,(t=>this._onInteraction(t,!1))),N.on(this._element,Us,(t=>this._onInteraction(t,!0))),N.on(this._element,Gs,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=ao.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(ao),m(ao),{Alert:Q,Button:Y,Carousel:xt,Collapse:Bt,Dropdown:qi,Modal:On,Offcanvas:qn,Popover:us,ScrollSpy:Es,Tab:Ks,Toast:ao,Tooltip:cs}})); +//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/rust/crates/du-web/assets/vendor/bootstrap.min.css b/rust/crates/du-web/assets/vendor/bootstrap.min.css new file mode 100644 index 0000000..3993414 --- /dev/null +++ b/rust/crates/du-web/assets/vendor/bootstrap.min.css @@ -0,0 +1,6 @@ +@charset "UTF-8";/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.form-floating>.form-control:disabled~label::after,.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-flush>.accordion-item>.accordion-collapse{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/rust/crates/du-web/assets/vendor/htmx.min.js b/rust/crates/du-web/assets/vendor/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/rust/crates/du-web/assets/vendor/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/rust/crates/du-web/src/htmx.rs b/rust/crates/du-web/src/htmx.rs new file mode 100644 index 0000000..2385125 --- /dev/null +++ b/rust/crates/du-web/src/htmx.rs @@ -0,0 +1,110 @@ +//! HTMX request/response plumbing for server-driven hypermedia (plan §4). + +use axum::extract::FromRequestParts; +use axum::http::header::{HeaderName, HeaderValue}; +use axum::http::request::Parts; +use axum::response::{IntoResponseParts, ResponseParts}; +use std::convert::Infallible; + +/// Whether the request came from HTMX, and whether it is a history-restore. +/// +/// `wants_fragment()` is the negotiation rule: serve just the inner fragment for +/// HTMX-driven swaps, but serve the FULL page for normal navigations AND for +/// htmx history restoration (back/forward), which expects the whole document. +pub struct HxRequest { + pub is_htmx: bool, + pub is_history_restore: bool, + /// The `HX-Target` element id, if the swap names one. + pub target: Option, +} + +impl HxRequest { + /// Serve the inner fragment only when this is an HTMX swap aimed at the + /// given target id — NOT for boosted full-page navigations (which target the + /// body and expect a whole document) nor history restoration. + pub fn wants_fragment_for(&self, target_id: &str) -> bool { + self.is_htmx + && !self.is_history_restore + && self.target.as_deref() == Some(target_id) + } +} + +#[axum::async_trait] +impl FromRequestParts for HxRequest { + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + let has = |name: &str| parts.headers.get(name).is_some_and(|v| v == "true"); + let target = parts + .headers + .get("hx-target") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + Ok(HxRequest { + is_htmx: has("hx-request"), + is_history_restore: has("hx-history-restore-request"), + target, + }) + } +} + +/// Builder for HTMX response headers, so state transitions are server-driven +/// (HX-Push-Url / HX-Trigger / HX-Redirect / HX-Location / HX-Reswap). +#[derive(Default)] +pub struct HxHeaders { + push_url: Option, + trigger: Option, + redirect: Option, + location: Option, + reswap: Option, +} + +// Builder surface kept complete for upcoming write flows (curator CRUD, forms); +// only push_url is exercised by the read-only slice so far. +#[allow(dead_code)] +impl HxHeaders { + pub fn new() -> Self { + Self::default() + } + pub fn push_url(mut self, url: impl Into) -> Self { + self.push_url = Some(url.into()); + self + } + pub fn trigger(mut self, event: impl Into) -> Self { + self.trigger = Some(event.into()); + self + } + pub fn redirect(mut self, url: impl Into) -> Self { + self.redirect = Some(url.into()); + self + } + pub fn location(mut self, url: impl Into) -> Self { + self.location = Some(url.into()); + self + } + pub fn reswap(mut self, spec: impl Into) -> Self { + self.reswap = Some(spec.into()); + self + } +} + +impl IntoResponseParts for HxHeaders { + type Error = Infallible; + + fn into_response_parts(self, mut res: ResponseParts) -> Result { + let headers = res.headers_mut(); + let mut set = |name: &'static str, val: Option| { + if let Some(v) = val { + if let Ok(hv) = HeaderValue::from_str(&v) { + headers.insert(HeaderName::from_static(name), hv); + } + } + }; + set("hx-push-url", self.push_url); + set("hx-trigger", self.trigger); + set("hx-redirect", self.redirect); + set("hx-location", self.location); + set("hx-reswap", self.reswap); + Ok(res) + } +} diff --git a/rust/crates/du-web/src/i18n.rs b/rust/crates/du-web/src/i18n.rs new file mode 100644 index 0000000..5bb33ca --- /dev/null +++ b/rust/crates/du-web/src/i18n.rs @@ -0,0 +1,188 @@ +//! Lightweight i18n: Play-style `key=value` catalogs embedded at compile time, +//! a `Lang` + `T` translator, and a `Locale` extractor that resolves the active +//! language from the `lang` cookie then `Accept-Language` (default English). +//! +//! Replaces Play's `messages`/`Messages`. Keeping it dependency-free (no fluent) +//! matches the catalog format the project already used. + +use axum::extract::FromRequestParts; +use axum::http::header::{ACCEPT_LANGUAGE, COOKIE}; +use axum::http::request::Parts; +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::OnceLock; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Lang { + En, + Es, + Fr, +} + +impl Lang { + pub fn code(self) -> &'static str { + match self { + Lang::En => "en", + Lang::Es => "es", + Lang::Fr => "fr", + } + } + + pub fn parse(s: &str) -> Option { + match s.get(0..2).map(str::to_ascii_lowercase).as_deref() { + Some("en") => Some(Lang::En), + Some("es") => Some(Lang::Es), + Some("fr") => Some(Lang::Fr), + _ => None, + } + } + + /// All languages, for rendering the switcher. + pub fn all() -> [Lang; 3] { + [Lang::En, Lang::Es, Lang::Fr] + } +} + +const EN_SRC: &str = include_str!("../../../locales/en.txt"); +const ES_SRC: &str = include_str!("../../../locales/es.txt"); +const FR_SRC: &str = include_str!("../../../locales/fr.txt"); + +fn parse_catalog(src: &'static str) -> HashMap<&'static str, &'static str> { + src.lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + line.split_once('=').map(|(k, v)| (k.trim(), v.trim())) + }) + .collect() +} + +fn catalog(lang: Lang) -> &'static HashMap<&'static str, &'static str> { + static EN: OnceLock> = OnceLock::new(); + static ES: OnceLock> = OnceLock::new(); + static FR: OnceLock> = OnceLock::new(); + match lang { + Lang::En => EN.get_or_init(|| parse_catalog(EN_SRC)), + Lang::Es => ES.get_or_init(|| parse_catalog(ES_SRC)), + Lang::Fr => FR.get_or_init(|| parse_catalog(FR_SRC)), + } +} + +/// Translator for one language. Cheap to copy. +#[derive(Clone, Copy)] +pub struct T { + pub lang: Lang, +} + +impl T { + pub fn new(lang: Lang) -> Self { + T { lang } + } + + /// Look up a key in the active language, falling back to English, then to + /// the key itself. Returned slices are `'static` (from the embedded catalogs) + /// or the borrowed key, so no allocation. + pub fn get<'a>(&self, key: &'a str) -> &'a str { + catalog(self.lang) + .get(key) + .or_else(|| catalog(Lang::En).get(key)) + .copied() + .unwrap_or(key) + } + + /// True when `lang` is the active language (for highlighting the switcher). + pub fn is(&self, lang: Lang) -> bool { + self.lang == lang + } + + /// Options for the language switcher: (code, localized label, active). + pub fn languages(&self) -> Vec { + Lang::all() + .into_iter() + .map(|l| LangOption { + code: l.code(), + label: self.get(match l { + Lang::En => "lang.en", + Lang::Es => "lang.es", + Lang::Fr => "lang.fr", + }), + active: self.is(l), + }) + .collect() + } +} + +pub struct LangOption { + pub code: &'static str, + pub label: &'static str, + pub active: bool, +} + +/// Per-request locale: the translator plus the current path (percent-encoded) +/// so the language switcher can return the user to the same page. +pub struct Locale { + pub t: T, + /// Current path+query, percent-encoded for use as a `?next=` value. + pub next: String, +} + +fn lang_from_cookie(parts: &Parts) -> Option { + let raw = parts.headers.get(COOKIE)?.to_str().ok()?; + raw.split(';') + .filter_map(|kv| kv.trim().split_once('=')) + .find(|(k, _)| *k == "lang") + .and_then(|(_, v)| Lang::parse(v)) +} + +fn lang_from_accept(parts: &Parts) -> Option { + let raw = parts.headers.get(ACCEPT_LANGUAGE)?.to_str().ok()?; + // First tag wins (ignore q-weights for our small set). + raw.split(',').next().and_then(|tag| Lang::parse(tag.trim())) +} + +#[axum::async_trait] +impl FromRequestParts for Locale { + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + let lang = lang_from_cookie(parts) + .or_else(|| lang_from_accept(parts)) + .unwrap_or(Lang::En); + let path_q = parts + .uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + let next = percent_encoding::utf8_percent_encode( + path_q, + percent_encoding::NON_ALPHANUMERIC, + ) + .to_string(); + Ok(Locale { t: T::new(lang), next }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fallback_chain_en_then_key() { + let es = T::new(Lang::Es); + assert_eq!(es.get("nav.home"), "Inicio"); + // a key only present implicitly falls back to English, then the key. + assert_eq!(es.get("does.not.exist"), "does.not.exist"); + } + + #[test] + fn catalogs_share_keys_with_english() { + let en = catalog(Lang::En); + for lang in [Lang::Es, Lang::Fr] { + for k in en.keys() { + assert!(catalog(lang).contains_key(k), "{} missing key {k}", lang.code()); + } + } + } +} diff --git a/rust/crates/du-web/src/main.rs b/rust/crates/du-web/src/main.rs index 9a221da..e433e2f 100644 --- a/rust/crates/du-web/src/main.rs +++ b/rust/crates/du-web/src/main.rs @@ -6,6 +6,8 @@ use std::net::SocketAddr; mod error; +mod htmx; +mod i18n; mod render; mod routes; mod state; diff --git a/rust/crates/du-web/src/routes/mod.rs b/rust/crates/du-web/src/routes/mod.rs index 025b176..56bcbb0 100644 --- a/rust/crates/du-web/src/routes/mod.rs +++ b/rust/crates/du-web/src/routes/mod.rs @@ -1,22 +1,37 @@ //! Router assembly + top-level pages. +use crate::i18n::{Lang, Locale, T}; use crate::render::html; use crate::state::AppState; +use axum::extract::{Path, Query}; +use axum::http::header::{LOCATION, SET_COOKIE}; use axum::http::StatusCode; -use axum::response::Response; +use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::Router; +use serde::Deserialize; +use tower_http::services::ServeDir; pub mod tree; pub mod variants; +/// Directory holding vendored static assets. Settable for deployment +/// (Dockerfile sets DU_ASSETS_DIR=/app/assets); falls back to the crate's +/// assets dir for local `cargo run`. +fn assets_dir() -> String { + std::env::var("DU_ASSETS_DIR") + .unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/assets").to_string()) +} + /// Full application router (requires a DB-backed AppState). pub fn app(state: AppState) -> Router { Router::new() .route("/health", get(health)) .route("/", get(index)) + .route("/language/:lang", get(switch_language)) .merge(variants::router()) .merge(tree::router()) + .nest_service("/assets", ServeDir::new(assets_dir())) .with_state(state) } @@ -31,10 +46,37 @@ async fn health() -> (StatusCode, &'static str) { #[derive(askama::Template)] #[template(path = "index.html")] -struct IndexTemplate; +struct IndexTemplate { + t: T, + next: String, +} + +async fn index(locale: Locale) -> Response { + html(&IndexTemplate { t: locale.t, next: locale.next }) +} + +#[derive(Deserialize)] +struct NextQuery { + next: Option, +} -async fn index() -> Response { - html(&IndexTemplate) +/// Set the `lang` cookie and redirect back. Only same-site relative paths are +/// honored as the redirect target (open-redirect guard, like the legacy app). +async fn switch_language(Path(lang): Path, Query(q): Query) -> Response { + let chosen = Lang::parse(&lang).unwrap_or(Lang::En); + let next = q + .next + .filter(|n| n.starts_with('/') && !n.starts_with("//")) + .unwrap_or_else(|| "/".to_string()); + let cookie = format!( + "lang={}; Path=/; Max-Age=31536000; SameSite=Lax", + chosen.code() + ); + ( + StatusCode::SEE_OTHER, + [(SET_COOKIE, cookie), (LOCATION, next)], + ) + .into_response() } #[cfg(test)] diff --git a/rust/crates/du-web/src/routes/tree.rs b/rust/crates/du-web/src/routes/tree.rs index be24d72..92f92e7 100644 --- a/rust/crates/du-web/src/routes/tree.rs +++ b/rust/crates/du-web/src/routes/tree.rs @@ -1,23 +1,27 @@ -//! Public Y/MT haplogroup tree navigation. `/ytree` and `/mtree` are full pages -//! whose `#tree-container` lazy-loads a fragment; clicking a node swaps the -//! fragment and pushes the URL (HATEOAS navigation, no client routing). +//! Public Y/MT haplogroup tree navigation, unified into ONE handler per lineage +//! (plan §4): a normal request returns the full page with the current level +//! embedded inline; an HTMX swap of `#tree-container` returns just the fragment +//! and sets `HX-Push-Url` server-side. History-restore and boosted navigations +//! get the full page. use crate::error::AppError; +use crate::htmx::{HxHeaders, HxRequest}; +use crate::i18n::{Locale, T}; use crate::render::html; use crate::state::AppState; use axum::extract::{Query, State}; -use axum::response::Response; +use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::Router; use du_domain::enums::DnaType; use serde::Deserialize; +const TARGET: &str = "tree-container"; + pub fn router() -> Router { Router::new() - .route("/ytree", get(ytree_page)) - .route("/mtree", get(mtree_page)) - .route("/ytree/fragment", get(ytree_fragment)) - .route("/mtree/fragment", get(mtree_fragment)) + .route("/ytree", get(ytree)) + .route("/mtree", get(mtree)) } #[derive(Deserialize)] @@ -25,48 +29,57 @@ struct RootQuery { root: Option, } +struct NodeView { + name: String, + formed_ybp: Option, +} + #[derive(askama::Template)] #[template(path = "tree/page.html")] struct TreePageTemplate { - title: &'static str, + t: T, + next: String, + title: String, base_path: &'static str, - root: Option, -} - -struct NodeView { - name: String, - formed_ybp: Option, + current: Option, + nodes: Vec, } #[derive(askama::Template)] #[template(path = "tree/fragment.html")] struct FragmentTemplate { + t: T, base_path: &'static str, current: Option, nodes: Vec, } -async fn ytree_page(Query(q): Query) -> Response { - html(&TreePageTemplate { title: "Y-DNA Tree", base_path: "/ytree", root: q.root }) -} - -async fn mtree_page(Query(q): Query) -> Response { - html(&TreePageTemplate { title: "mtDNA Tree", base_path: "/mtree", root: q.root }) -} - -async fn ytree_fragment(st: State, q: Query) -> Result { - fragment(st, q, DnaType::YDna, "/ytree").await +async fn ytree( + st: State, + hx: HxRequest, + locale: Locale, + q: Query, +) -> Result { + render_tree(st, hx, locale, q, DnaType::YDna, "/ytree", "tree.title.y").await } -async fn mtree_fragment(st: State, q: Query) -> Result { - fragment(st, q, DnaType::MtDna, "/mtree").await +async fn mtree( + st: State, + hx: HxRequest, + locale: Locale, + q: Query, +) -> Result { + render_tree(st, hx, locale, q, DnaType::MtDna, "/mtree", "tree.title.mt").await } -async fn fragment( +async fn render_tree( State(st): State, + hx: HxRequest, + locale: Locale, Query(q): Query, dna_type: DnaType, base_path: &'static str, + title_key: &str, ) -> Result { let to_view = |h: du_domain::haplogroup::Haplogroup| NodeView { name: h.name, @@ -83,10 +96,26 @@ async fn fragment( (Some(cur), kids) } }; + let current = current.map(to_view); + let nodes: Vec = nodes.into_iter().map(to_view).collect(); - Ok(html(&FragmentTemplate { - base_path, - current: current.map(to_view), - nodes: nodes.into_iter().map(to_view).collect(), - })) + if hx.wants_fragment_for(TARGET) { + // Server drives the URL bar for the swap. + let push = match ¤t { + Some(n) => format!("{base_path}?root={}", n.name), + None => base_path.to_string(), + }; + let frag = FragmentTemplate { t: locale.t, base_path, current, nodes }; + Ok((HxHeaders::new().push_url(push), html(&frag)).into_response()) + } else { + let page = TreePageTemplate { + t: locale.t, + next: locale.next, + title: locale.t.get(title_key).to_string(), + base_path, + current, + nodes, + }; + Ok(html(&page)) + } } diff --git a/rust/crates/du-web/src/routes/variants.rs b/rust/crates/du-web/src/routes/variants.rs index 13c64b8..72073eb 100644 --- a/rust/crates/du-web/src/routes/variants.rs +++ b/rust/crates/du-web/src/routes/variants.rs @@ -1,8 +1,9 @@ -//! Public variant browser. Demonstrates the HTMX two-panel + fragment pattern: -//! a full page (`/variants`) that lazy-loads a list fragment (`/variants/list`), -//! whose rows load a detail panel fragment (`/variants/detail/:id`). +//! Public variant browser. The browser page embeds the first results page inline +//! (no load round-trip); search/pagination and the detail panel are HTMX +//! fragments targeting `#variants-table` / `#detail-panel`. use crate::error::AppError; +use crate::i18n::{Locale, T}; use crate::render::html; use crate::state::AppState; use axum::extract::{Path, Query, State}; @@ -51,21 +52,44 @@ impl RowView { } } +/// Shared list-fragment view data (also embedded by the browser page). +struct ListView { + query: String, + rows: Vec, + page: i64, + page_size: i64, + total: i64, + total_pages: i64, +} + +async fn load_list(st: &AppState, q: &ListQuery) -> Result { + let page_num = q.page.unwrap_or(1); + let page_size = q.page_size.unwrap_or(25); + let result: Page = + du_db::variant::search(&st.pool, q.query.as_deref(), page_num, page_size).await?; + Ok(ListView { + query: q.query.clone().unwrap_or_default(), + rows: result.items.iter().map(RowView::from).collect(), + page: result.page, + page_size: result.page_size, + total: result.total, + total_pages: result.total_pages(), + }) +} + #[derive(askama::Template)] #[template(path = "variants/browser.html")] struct BrowserTemplate { - query: String, + t: T, + next: String, + list: ListView, } #[derive(askama::Template)] #[template(path = "variants/list.html")] struct ListTemplate { - query: String, - rows: Vec, - page: i64, - page_size: i64, - total: i64, - total_pages: i64, + t: T, + list: ListView, } struct CoordView { @@ -78,6 +102,7 @@ struct CoordView { #[derive(askama::Template)] #[template(path = "variants/detail.html")] struct DetailTemplate { + t: T, name: String, mutation_type: String, naming_status: String, @@ -86,27 +111,29 @@ struct DetailTemplate { coords: Vec, } -async fn browser(Query(q): Query) -> Response { - html(&BrowserTemplate { query: q.query.unwrap_or_default() }) +async fn browser( + State(st): State, + locale: Locale, + Query(q): Query, +) -> Result { + let list = load_list(&st, &q).await?; + Ok(html(&BrowserTemplate { t: locale.t, next: locale.next, list })) } -async fn list(State(st): State, Query(q): Query) -> Result { - let page_num = q.page.unwrap_or(1); - let page_size = q.page_size.unwrap_or(25); - let result: Page = - du_db::variant::search(&st.pool, q.query.as_deref(), page_num, page_size).await?; - let rows = result.items.iter().map(RowView::from).collect(); - Ok(html(&ListTemplate { - query: q.query.unwrap_or_default(), - rows, - page: result.page, - page_size: result.page_size, - total: result.total, - total_pages: result.total_pages(), - })) +async fn list( + State(st): State, + locale: Locale, + Query(q): Query, +) -> Result { + let list = load_list(&st, &q).await?; + Ok(html(&ListTemplate { t: locale.t, list })) } -async fn detail(State(st): State, Path(id): Path) -> Result { +async fn detail( + State(st): State, + locale: Locale, + Path(id): Path, +) -> Result { let v = du_db::variant::get_by_id(&st.pool, VariantId(id)) .await? .ok_or_else(|| AppError::NotFound(format!("variant {id}")))?; @@ -128,6 +155,7 @@ async fn detail(State(st): State, Path(id): Path) -> Result - + - {% block title %}Decoding Us{% endblock %} - - - - + {% block title %}{{ t.get("app.name") }}{% endblock %} + + +
{% block content %}{% endblock %}
+ diff --git a/rust/crates/du-web/templates/index.html b/rust/crates/du-web/templates/index.html index f0a1bc3..73b3766 100644 --- a/rust/crates/du-web/templates/index.html +++ b/rust/crates/du-web/templates/index.html @@ -1,16 +1,12 @@ {% extends "base.html" %} -{% block title %}Decoding Us — genetic genealogy & population research{% endblock %} +{% block title %}{{ t.get("home.title") }}{% endblock %} {% block content %}
-

Decoding Us

-

- A collaborative platform for genetic genealogy and population research — - Y/mtDNA haplogroup trees, a public variant browser, and privacy-preserving - relative discovery. -

+

{{ t.get("home.heading") }}

+

{{ t.get("home.lead") }}

{% endblock %} diff --git a/rust/crates/du-web/templates/tree/fragment.html b/rust/crates/du-web/templates/tree/fragment.html index f4ceb9d..d1e2a09 100644 --- a/rust/crates/du-web/templates/tree/fragment.html +++ b/rust/crates/du-web/templates/tree/fragment.html @@ -1,29 +1,28 @@ -{# Fragment: one tree level. Clicking a node swaps this container and pushes the - URL so browser back/forward navigates the tree (HATEOAS). #} +{# Fragment: one tree level. Links hit the unified {base_path} endpoint targeting + #tree-container; the server returns this fragment and sets HX-Push-Url. The + href is the no-JS fallback. References `t`, `base_path`, `current`, `nodes`. #} {% if let Some(cur) = current %}

{{ cur.name }} - {% if let Some(ybp) = cur.formed_ybp %}· formed ~{{ ybp }} ybp{% endif %} + {% if let Some(ybp) = cur.formed_ybp %}· {{ t.get("tree.formed") }} ~{{ ybp }} ybp{% endif %}

{% else %} -

Root lineages

+

{{ t.get("tree.rootLineages") }}

{% endif %}
{% for n in nodes %} + hx-get="{{ base_path }}?root={{ n.name }}" + hx-target="#tree-container"> {{ n.name }} {% if let Some(ybp) = n.formed_ybp %}~{{ ybp }} ybp{% endif %} {% else %} -
No child haplogroups.
+
{{ t.get("tree.noChildren") }}
{% endfor %}
diff --git a/rust/crates/du-web/templates/tree/page.html b/rust/crates/du-web/templates/tree/page.html index b5d78af..1f9d72b 100644 --- a/rust/crates/du-web/templates/tree/page.html +++ b/rust/crates/du-web/templates/tree/page.html @@ -1,10 +1,9 @@ {% extends "base.html" %} -{% block title %}{{ title }} — Decoding Us{% endblock %} +{% block title %}{{ title }} — {{ t.get("app.name") }}{% endblock %} {% block content %}

{{ title }}

-
-
Loading tree… +{# Current level embedded inline; HTMX swaps just this container thereafter. #} +
+ {% include "tree/fragment.html" %}
{% endblock %} diff --git a/rust/crates/du-web/templates/variants/browser.html b/rust/crates/du-web/templates/variants/browser.html index dc36d38..42781d4 100644 --- a/rust/crates/du-web/templates/variants/browser.html +++ b/rust/crates/du-web/templates/variants/browser.html @@ -1,25 +1,25 @@ {% extends "base.html" %} -{% block title %}Variant Browser — Decoding Us{% endblock %} +{% block title %}{{ t.get("variants.title") }} — {{ t.get("app.name") }}{% endblock %} {% block content %} -

Variant Browser

+

{{ t.get("variants.title") }}

-
+ {# First page embedded inline (no load round-trip); search/pager swap it. #} +
+ {% include "variants/list.html" %}
-

Select a variant to see details.

+

{{ t.get("variants.detail.select") }}

diff --git a/rust/crates/du-web/templates/variants/detail.html b/rust/crates/du-web/templates/variants/detail.html index 8a19ace..45c381c 100644 --- a/rust/crates/du-web/templates/variants/detail.html +++ b/rust/crates/du-web/templates/variants/detail.html @@ -5,25 +5,30 @@ {{ mutation_type }}
-

Naming status: {{ naming_status }}

+

{{ t.get("variants.detail.naming") }} {{ naming_status }}

{% if !common_names.is_empty() %} -

Also known as: +

{{ t.get("variants.detail.aka") }} {% for n in common_names %}{{ n }} {% endfor %}

{% endif %} {% if !rs_ids.is_empty() %} -

rs IDs: +

{{ t.get("variants.detail.rsids") }} {% for r in rs_ids %}{{ r }} {% endfor %}

{% endif %} -
Coordinates
+
{{ t.get("variants.detail.coordinates") }}
{% if coords.is_empty() %} -

No mapped coordinates.

+

{{ t.get("variants.detail.nocoords") }}

{% else %} - + + + + + + {% for c in coords %} diff --git a/rust/crates/du-web/templates/variants/list.html b/rust/crates/du-web/templates/variants/list.html index fd42f17..c28adef 100644 --- a/rust/crates/du-web/templates/variants/list.html +++ b/rust/crates/du-web/templates/variants/list.html @@ -1,10 +1,16 @@ -{# Fragment: the variant results table + pager. Target of #variants-table. #} +{# Fragment: results table + pager. Target of #variants-table; also included by + the browser page. References `t` and `list`. #}
BuildContigPositionChange
{{ t.get("variants.col.build") }}{{ t.get("variants.col.contig") }}{{ t.get("variants.col.position") }}{{ t.get("variants.col.change") }}
- + + + + + + - {% for r in rows %} + {% for r in list.rows %} {{ r.builds }} {% else %} - + {% endfor %}
NameTypeStatusBuilds
{{ t.get("variants.col.name") }}{{ t.get("variants.col.type") }}{{ t.get("variants.col.status") }}{{ t.get("variants.col.builds") }}
No variants match.
{{ t.get("variants.none") }}
-{% if total_pages > 1 %} +{% if list.total_pages > 1 %} {% else %} -{{ total }} total +{{ list.total }} {{ t.get("pagination.total") }} {% endif %} diff --git a/rust/locales/en.txt b/rust/locales/en.txt new file mode 100644 index 0000000..ebcebc1 --- /dev/null +++ b/rust/locales/en.txt @@ -0,0 +1,48 @@ +# English message catalog. key=value, '#' comments. Ported subset (slice scope). +app.name=Decoding Us +nav.home=Home +nav.ytree=Y-DNA Tree +nav.mtree=mtDNA Tree +nav.variants=Variants +lang.label=Language +lang.en=English +lang.es=Español +lang.fr=Français + +home.title=Decoding Us — genetic genealogy & population research +home.heading=Decoding Us +home.lead=A collaborative platform for genetic genealogy and population research — Y/mtDNA haplogroup trees, a public variant browser, and privacy-preserving relative discovery. +home.cta.tree=Browse the Y-DNA tree +home.cta.variants=Search variants + +variants.title=Variant Browser +variants.search.placeholder=Search by name or alias (e.g. M269, rs9786153)… +variants.col.name=Name +variants.col.type=Type +variants.col.status=Status +variants.col.builds=Builds +variants.none=No variants match. +variants.detail.select=Select a variant to see details. +variants.detail.naming=Naming status: +variants.detail.aka=Also known as: +variants.detail.rsids=rs IDs: +variants.detail.coordinates=Coordinates +variants.detail.nocoords=No mapped coordinates. +variants.col.build=Build +variants.col.contig=Contig +variants.col.position=Position +variants.col.change=Change + +pagination.previous=Previous +pagination.next=Next +pagination.page=Page +pagination.of=of +pagination.total=total + +tree.title.y=Y-DNA Tree +tree.title.mt=mtDNA Tree +tree.loading=Loading tree… +tree.allRoots=← All roots +tree.rootLineages=Root lineages +tree.noChildren=No child haplogroups. +tree.formed=formed diff --git a/rust/locales/es.txt b/rust/locales/es.txt new file mode 100644 index 0000000..e6d3bfc --- /dev/null +++ b/rust/locales/es.txt @@ -0,0 +1,48 @@ +# Spanish message catalog. +app.name=Decoding Us +nav.home=Inicio +nav.ytree=Árbol Y-ADN +nav.mtree=Árbol ADNmt +nav.variants=Variantes +lang.label=Idioma +lang.en=English +lang.es=Español +lang.fr=Français + +home.title=Decoding Us — genealogía genética e investigación de poblaciones +home.heading=Decoding Us +home.lead=Una plataforma colaborativa para la genealogía genética y la investigación de poblaciones: árboles de haplogrupos Y/ADNmt, un explorador público de variantes y descubrimiento de parientes que preserva la privacidad. +home.cta.tree=Explorar el árbol Y-ADN +home.cta.variants=Buscar variantes + +variants.title=Explorador de variantes +variants.search.placeholder=Buscar por nombre o alias (p. ej. M269, rs9786153)… +variants.col.name=Nombre +variants.col.type=Tipo +variants.col.status=Estado +variants.col.builds=Ensamblajes +variants.none=No hay variantes coincidentes. +variants.detail.select=Seleccione una variante para ver los detalles. +variants.detail.naming=Estado de nomenclatura: +variants.detail.aka=También conocido como: +variants.detail.rsids=Identificadores rs: +variants.detail.coordinates=Coordenadas +variants.detail.nocoords=Sin coordenadas asignadas. +variants.col.build=Ensamblaje +variants.col.contig=Contig +variants.col.position=Posición +variants.col.change=Cambio + +pagination.previous=Anterior +pagination.next=Siguiente +pagination.page=Página +pagination.of=de +pagination.total=en total + +tree.title.y=Árbol Y-ADN +tree.title.mt=Árbol ADNmt +tree.loading=Cargando el árbol… +tree.allRoots=← Todas las raíces +tree.rootLineages=Linajes raíz +tree.noChildren=Sin haplogrupos descendientes. +tree.formed=formado diff --git a/rust/locales/fr.txt b/rust/locales/fr.txt new file mode 100644 index 0000000..bd1c732 --- /dev/null +++ b/rust/locales/fr.txt @@ -0,0 +1,48 @@ +# French message catalog. +app.name=Decoding Us +nav.home=Accueil +nav.ytree=Arbre Y-ADN +nav.mtree=Arbre ADNmt +nav.variants=Variants +lang.label=Langue +lang.en=English +lang.es=Español +lang.fr=Français + +home.title=Decoding Us — généalogie génétique et recherche sur les populations +home.heading=Decoding Us +home.lead=Une plateforme collaborative pour la généalogie génétique et la recherche sur les populations : arbres d’haplogroupes Y/ADNmt, un explorateur public de variants et une découverte de parents respectueuse de la vie privée. +home.cta.tree=Explorer l’arbre Y-ADN +home.cta.variants=Rechercher des variants + +variants.title=Explorateur de variants +variants.search.placeholder=Rechercher par nom ou alias (p. ex. M269, rs9786153)… +variants.col.name=Nom +variants.col.type=Type +variants.col.status=Statut +variants.col.builds=Assemblages +variants.none=Aucun variant correspondant. +variants.detail.select=Sélectionnez un variant pour voir les détails. +variants.detail.naming=Statut de nomenclature : +variants.detail.aka=Aussi connu sous le nom de : +variants.detail.rsids=Identifiants rs : +variants.detail.coordinates=Coordonnées +variants.detail.nocoords=Aucune coordonnée cartographiée. +variants.col.build=Assemblage +variants.col.contig=Contig +variants.col.position=Position +variants.col.change=Changement + +pagination.previous=Précédent +pagination.next=Suivant +pagination.page=Page +pagination.of=sur +pagination.total=au total + +tree.title.y=Arbre Y-ADN +tree.title.mt=Arbre ADNmt +tree.loading=Chargement de l’arbre… +tree.allRoots=← Toutes les racines +tree.rootLineages=Lignées racines +tree.noChildren=Aucun haplogroupe descendant. +tree.formed=formé From ae1fba9208588c7510121fb0223cc0f278ccd671 Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 08:22:39 -0500 Subject: [PATCH 005/191] feat(rust): public references + per-publication biosample report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broaden the public surface, reusing the publication/biosample query modules and the established HTMX two-panel + i18n patterns. - du-db: biosample::for_publication — paginated biosamples linked to a publication (join pubs.publication_biosample), returning Page. - du-domain: Display/label() for BiosampleSource. - du-web references routes: /references (page, first list embedded inline), /references/list (search + pagination fragment), /references/:id/biosamples (per-publication report fragment, paginated). Clicking a publication loads its samples into #reference-detail. 404 on missing publication. - i18n: references/* keys added across en/es/fr; nav "References" link. - Templates references/{page,list,biosamples}.html. Verified live against the container PostGIS (list, search, es localization, ancient/external sample reports with DOI link). Workspace 9/9 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/du-db/src/biosample.rs | 44 ++++- rust/crates/du-domain/src/enums.rs | 17 ++ rust/crates/du-web/src/routes/mod.rs | 2 + rust/crates/du-web/src/routes/references.rs | 167 ++++++++++++++++++ rust/crates/du-web/templates/base.html | 1 + .../templates/references/biosamples.html | 45 +++++ .../du-web/templates/references/list.html | 35 ++++ .../du-web/templates/references/page.html | 25 +++ rust/locales/en.txt | 17 ++ rust/locales/es.txt | 17 ++ rust/locales/fr.txt | 17 ++ 11 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 rust/crates/du-web/src/routes/references.rs create mode 100644 rust/crates/du-web/templates/references/biosamples.html create mode 100644 rust/crates/du-web/templates/references/list.html create mode 100644 rust/crates/du-web/templates/references/page.html diff --git a/rust/crates/du-db/src/biosample.rs b/rust/crates/du-db/src/biosample.rs index 2d1f7cb..de70c70 100644 --- a/rust/crates/du-db/src/biosample.rs +++ b/rust/crates/du-db/src/biosample.rs @@ -1,8 +1,8 @@ //! Queries for the unified `core.biosample`. -use crate::{parse_pg_enum, DbError}; +use crate::{parse_pg_enum, DbError, Page}; use du_domain::biosample::Biosample; -use du_domain::ids::SampleGuid; +use du_domain::ids::{PublicationId, SampleGuid}; use sqlx::PgPool; use uuid::Uuid; @@ -46,6 +46,46 @@ pub async fn get_by_guid(pool: &PgPool, guid: SampleGuid) -> Result Result, DbError> { + let offset = Page::<()>::offset(page, page_size); + let limit = page_size.clamp(1, 200); + + let total: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pubs.publication_biosample pb \ + JOIN core.biosample b ON b.sample_guid = pb.sample_guid \ + WHERE pb.publication_id = $1 AND b.deleted = false", + ) + .bind(publication_id.0) + .fetch_one(pool) + .await?; + + let rows: Vec = sqlx::query_as( + "SELECT b.sample_guid, b.source::text AS source, b.accession, b.alias, b.description, \ + b.center_name, b.locked, b.source_attrs, b.atproto \ + FROM pubs.publication_biosample pb \ + JOIN core.biosample b ON b.sample_guid = pb.sample_guid \ + WHERE pb.publication_id = $1 AND b.deleted = false \ + ORDER BY b.accession NULLS LAST, b.sample_guid LIMIT $2 OFFSET $3", + ) + .bind(publication_id.0) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + let items = rows + .into_iter() + .map(BiosampleRow::into_domain) + .collect::, _>>()?; + Ok(Page { items, total, page: page.max(1), page_size: limit }) +} + /// Lookup by accession or alias (the private biosample search). pub async fn find_by_alias_or_accession( pool: &PgPool, diff --git a/rust/crates/du-domain/src/enums.rs b/rust/crates/du-domain/src/enums.rs index c2710c5..69c9148 100644 --- a/rust/crates/du-domain/src/enums.rs +++ b/rust/crates/du-domain/src/enums.rs @@ -117,6 +117,23 @@ impl std::fmt::Display for DnaType { } } +impl BiosampleSource { + pub fn label(&self) -> &'static str { + match self { + BiosampleSource::Standard => "STANDARD", + BiosampleSource::Citizen => "CITIZEN", + BiosampleSource::Pgp => "PGP", + BiosampleSource::External => "EXTERNAL", + BiosampleSource::Ancient => "ANCIENT", + } + } +} +impl std::fmt::Display for BiosampleSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + impl MutationType { pub fn label(&self) -> &'static str { match self { diff --git a/rust/crates/du-web/src/routes/mod.rs b/rust/crates/du-web/src/routes/mod.rs index 56bcbb0..5aeb73d 100644 --- a/rust/crates/du-web/src/routes/mod.rs +++ b/rust/crates/du-web/src/routes/mod.rs @@ -12,6 +12,7 @@ use axum::Router; use serde::Deserialize; use tower_http::services::ServeDir; +pub mod references; pub mod tree; pub mod variants; @@ -31,6 +32,7 @@ pub fn app(state: AppState) -> Router { .route("/language/:lang", get(switch_language)) .merge(variants::router()) .merge(tree::router()) + .merge(references::router()) .nest_service("/assets", ServeDir::new(assets_dir())) .with_state(state) } diff --git a/rust/crates/du-web/src/routes/references.rs b/rust/crates/du-web/src/routes/references.rs new file mode 100644 index 0000000..aa2111f --- /dev/null +++ b/rust/crates/du-web/src/routes/references.rs @@ -0,0 +1,167 @@ +//! Public references (publications) + per-publication biosample report. +//! Same two-panel HTMX pattern as the variant browser: a searchable/paginated +//! publication list on the left, the selected publication's samples on the right. + +use crate::error::AppError; +use crate::i18n::{Locale, T}; +use crate::render::html; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::response::Response; +use axum::routing::get; +use axum::Router; +use du_domain::ids::PublicationId; +use serde::Deserialize; + +pub fn router() -> Router { + Router::new() + .route("/references", get(page)) + .route("/references/list", get(list)) + .route("/references/:id/biosamples", get(biosamples)) +} + +#[derive(Deserialize)] +struct ListQuery { + query: Option, + page: Option, + page_size: Option, +} + +#[derive(Deserialize)] +struct PageQuery { + page: Option, + page_size: Option, +} + +struct PubRow { + id: i64, + title: String, + journal: String, + year: String, + citations: Option, +} + +struct PubListView { + query: String, + rows: Vec, + page: i64, + page_size: i64, + total: i64, + total_pages: i64, +} + +async fn load_list(st: &AppState, q: &ListQuery) -> Result { + let result = + du_db::publication::search(&st.pool, q.query.as_deref(), q.page.unwrap_or(1), q.page_size.unwrap_or(20)) + .await?; + let rows = result + .items + .iter() + .map(|p| PubRow { + id: p.id.0, + title: p.title.clone(), + journal: p.journal.clone().unwrap_or_default(), + year: p.publication_date.map(|d| d.format("%Y").to_string()).unwrap_or_default(), + citations: p.cited_by_count, + }) + .collect(); + Ok(PubListView { + query: q.query.clone().unwrap_or_default(), + rows, + page: result.page, + page_size: result.page_size, + total: result.total, + total_pages: result.total_pages(), + }) +} + +#[derive(askama::Template)] +#[template(path = "references/page.html")] +struct ReferencesPageTemplate { + t: T, + next: String, + list: PubListView, +} + +#[derive(askama::Template)] +#[template(path = "references/list.html")] +struct PubListTemplate { + t: T, + list: PubListView, +} + +struct BioRow { + source: String, + accession: String, + alias: String, + description: String, +} + +#[derive(askama::Template)] +#[template(path = "references/biosamples.html")] +struct BiosamplesTemplate { + t: T, + pub_id: i64, + pub_title: String, + pub_doi: Option, + rows: Vec, + page: i64, + page_size: i64, + total: i64, + total_pages: i64, +} + +async fn page( + State(st): State, + locale: Locale, + Query(q): Query, +) -> Result { + let list = load_list(&st, &q).await?; + Ok(html(&ReferencesPageTemplate { t: locale.t, next: locale.next, list })) +} + +async fn list( + State(st): State, + locale: Locale, + Query(q): Query, +) -> Result { + let list = load_list(&st, &q).await?; + Ok(html(&PubListTemplate { t: locale.t, list })) +} + +async fn biosamples( + State(st): State, + locale: Locale, + Path(id): Path, + Query(q): Query, +) -> Result { + let pub_id = PublicationId(id); + let publication = du_db::publication::get_by_id(&st.pool, pub_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("publication {id}")))?; + let result = + du_db::biosample::for_publication(&st.pool, pub_id, q.page.unwrap_or(1), q.page_size.unwrap_or(25)) + .await?; + let rows = result + .items + .iter() + .map(|b| BioRow { + source: b.source.label().to_string(), + accession: b.accession.clone().unwrap_or_default(), + alias: b.alias.clone().unwrap_or_default(), + description: b.description.clone().unwrap_or_default(), + }) + .collect(); + + Ok(html(&BiosamplesTemplate { + t: locale.t, + pub_id: id, + pub_title: publication.title, + pub_doi: publication.doi, + rows, + page: result.page, + page_size: result.page_size, + total: result.total, + total_pages: result.total_pages(), + })) +} diff --git a/rust/crates/du-web/templates/base.html b/rust/crates/du-web/templates/base.html index 2953a38..a2b183f 100644 --- a/rust/crates/du-web/templates/base.html +++ b/rust/crates/du-web/templates/base.html @@ -16,6 +16,7 @@ {{ t.get("nav.ytree") }} {{ t.get("nav.mtree") }} {{ t.get("nav.variants") }} + {{ t.get("nav.references") }}
+
diff --git a/rust/crates/du-web/templates/references/list.html b/rust/crates/du-web/templates/references/list.html new file mode 100644 index 0000000..f757df7 --- /dev/null +++ b/rust/crates/du-web/templates/references/list.html @@ -0,0 +1,35 @@ +{# Fragment: publication list + pager. Target of #references-table. #} + + +{% if list.total_pages > 1 %} + +{% else %} +{{ list.total }} {{ t.get("pagination.total") }} +{% endif %} diff --git a/rust/crates/du-web/templates/references/page.html b/rust/crates/du-web/templates/references/page.html new file mode 100644 index 0000000..85c0a42 --- /dev/null +++ b/rust/crates/du-web/templates/references/page.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}{{ t.get("references.title") }} — {{ t.get("app.name") }}{% endblock %} +{% block content %} +

{{ t.get("references.title") }}

+
+
+ + +
+ {% include "references/list.html" %} +
+
+
+
+

{{ t.get("references.select") }}

+
+
+
+{% endblock %} diff --git a/rust/locales/en.txt b/rust/locales/en.txt index ebcebc1..9a9aa2d 100644 --- a/rust/locales/en.txt +++ b/rust/locales/en.txt @@ -4,6 +4,7 @@ nav.home=Home nav.ytree=Y-DNA Tree nav.mtree=mtDNA Tree nav.variants=Variants +nav.references=References lang.label=Language lang.en=English lang.es=Español @@ -46,3 +47,19 @@ tree.allRoots=← All roots tree.rootLineages=Root lineages tree.noChildren=No child haplogroups. tree.formed=formed + +references.title=References +references.search.placeholder=Search by title, journal, or DOI… +references.col.title=Title +references.col.journal=Journal +references.col.year=Year +references.col.citations=Citations +references.none=No publications match. +references.select=Select a publication to see its samples. +references.viewDoi=DOI +references.biosamples.title=Samples in this study +references.biosamples.none=No samples linked to this publication. +references.col.accession=Accession +references.col.alias=Alias +references.col.source=Source +references.col.description=Description diff --git a/rust/locales/es.txt b/rust/locales/es.txt index e6d3bfc..850bdb3 100644 --- a/rust/locales/es.txt +++ b/rust/locales/es.txt @@ -4,6 +4,7 @@ nav.home=Inicio nav.ytree=Árbol Y-ADN nav.mtree=Árbol ADNmt nav.variants=Variantes +nav.references=Referencias lang.label=Idioma lang.en=English lang.es=Español @@ -46,3 +47,19 @@ tree.allRoots=← Todas las raíces tree.rootLineages=Linajes raíz tree.noChildren=Sin haplogrupos descendientes. tree.formed=formado + +references.title=Referencias +references.search.placeholder=Buscar por título, revista o DOI… +references.col.title=Título +references.col.journal=Revista +references.col.year=Año +references.col.citations=Citas +references.none=No hay publicaciones coincidentes. +references.select=Seleccione una publicación para ver sus muestras. +references.viewDoi=DOI +references.biosamples.title=Muestras de este estudio +references.biosamples.none=No hay muestras vinculadas a esta publicación. +references.col.accession=Número de acceso +references.col.alias=Alias +references.col.source=Origen +references.col.description=Descripción diff --git a/rust/locales/fr.txt b/rust/locales/fr.txt index bd1c732..1ba3486 100644 --- a/rust/locales/fr.txt +++ b/rust/locales/fr.txt @@ -4,6 +4,7 @@ nav.home=Accueil nav.ytree=Arbre Y-ADN nav.mtree=Arbre ADNmt nav.variants=Variants +nav.references=Références lang.label=Langue lang.en=English lang.es=Español @@ -46,3 +47,19 @@ tree.allRoots=← Toutes les racines tree.rootLineages=Lignées racines tree.noChildren=Aucun haplogroupe descendant. tree.formed=formé + +references.title=Références +references.search.placeholder=Rechercher par titre, revue ou DOI… +references.col.title=Titre +references.col.journal=Revue +references.col.year=Année +references.col.citations=Citations +references.none=Aucune publication correspondante. +references.select=Sélectionnez une publication pour voir ses échantillons. +references.viewDoi=DOI +references.biosamples.title=Échantillons de cette étude +references.biosamples.none=Aucun échantillon lié à cette publication. +references.col.accession=Numéro d’accès +references.col.alias=Alias +references.col.source=Origine +references.col.description=Description From 089660940a5c933a13102f95301c3548add70dc5 Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 08:34:33 -0500 Subject: [PATCH 006/191] feat(rust): biosample map (PostGIS + Leaflet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the geographic map and exercises the PostGIS spatial path end-to-end (de-risks plan §11's PostGIS-in-Rust concern). - du-db: biosample::geo_points — ST_X/ST_Y over the donor geocoord (geometry Point, 4326), joined to non-deleted biosamples. - du-domain: biosample::GeoPoint (serde). - du-web maps routes: /biosamples/map (page) and /biosamples/geo-data (GeoJSON FeatureCollection, [lon,lat] order). map_page is a full-page load (nav link hx-boost=false) so Leaflet initializes; base.html gains a head block for per-page assets. - Vendored Leaflet 1.9.4 (css + js); assets/map.js plots circle markers (no marker-image assets) with popups and fits bounds. OSM tiles at runtime. - i18n map.* keys + nav "Map" across en/es/fr. Verified live: GeoJSON has 3 features with correct [lon,lat] coords and accession/source props; assets served; es localization. Workspace 9/9 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/du-db/src/biosample.rs | 32 +- rust/crates/du-domain/src/biosample.rs | 9 + rust/crates/du-web/assets/map.js | 34 + rust/crates/du-web/assets/vendor/leaflet.css | 661 ++++++++++++++++++ rust/crates/du-web/assets/vendor/leaflet.js | 6 + rust/crates/du-web/src/routes/maps.rs | 46 ++ rust/crates/du-web/src/routes/mod.rs | 2 + rust/crates/du-web/templates/base.html | 3 + .../du-web/templates/biosamples/map.html | 15 + rust/locales/en.txt | 5 + rust/locales/es.txt | 5 + rust/locales/fr.txt | 5 + 12 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 rust/crates/du-web/assets/map.js create mode 100644 rust/crates/du-web/assets/vendor/leaflet.css create mode 100644 rust/crates/du-web/assets/vendor/leaflet.js create mode 100644 rust/crates/du-web/src/routes/maps.rs create mode 100644 rust/crates/du-web/templates/biosamples/map.html diff --git a/rust/crates/du-db/src/biosample.rs b/rust/crates/du-db/src/biosample.rs index de70c70..f65b18e 100644 --- a/rust/crates/du-db/src/biosample.rs +++ b/rust/crates/du-db/src/biosample.rs @@ -1,7 +1,7 @@ //! Queries for the unified `core.biosample`. use crate::{parse_pg_enum, DbError, Page}; -use du_domain::biosample::Biosample; +use du_domain::biosample::{Biosample, GeoPoint}; use du_domain::ids::{PublicationId, SampleGuid}; use sqlx::PgPool; use uuid::Uuid; @@ -46,6 +46,36 @@ pub async fn get_by_guid(pool: &PgPool, guid: SampleGuid) -> Result Result, DbError> { + #[derive(sqlx::FromRow)] + struct GeoRow { + lat: f64, + lon: f64, + accession: Option, + source: String, + } + let rows: Vec = sqlx::query_as( + "SELECT ST_Y(d.geocoord) AS lat, ST_X(d.geocoord) AS lon, b.accession, \ + b.source::text AS source \ + FROM core.biosample b JOIN core.specimen_donor d ON d.id = b.donor_id \ + WHERE d.geocoord IS NOT NULL AND b.deleted = false", + ) + .fetch_all(pool) + .await?; + rows.into_iter() + .map(|r| { + Ok(GeoPoint { + lat: r.lat, + lon: r.lon, + accession: r.accession, + source: parse_pg_enum(&r.source, "source")?, + }) + }) + .collect() +} + /// Paginated biosamples linked to a publication (the biosample report). pub async fn for_publication( pool: &PgPool, diff --git a/rust/crates/du-domain/src/biosample.rs b/rust/crates/du-domain/src/biosample.rs index 0e5a227..2778c40 100644 --- a/rust/crates/du-domain/src/biosample.rs +++ b/rust/crates/du-domain/src/biosample.rs @@ -6,6 +6,15 @@ use crate::enums::BiosampleSource; use crate::ids::SampleGuid; use serde::{Deserialize, Serialize}; +/// A mappable biosample location (from the donor's `geocoord`, WGS84). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeoPoint { + pub lat: f64, + pub lon: f64, + pub accession: Option, + pub source: BiosampleSource, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Biosample { pub sample_guid: SampleGuid, diff --git a/rust/crates/du-web/assets/map.js b/rust/crates/du-web/assets/map.js new file mode 100644 index 0000000..05b170c --- /dev/null +++ b/rust/crates/du-web/assets/map.js @@ -0,0 +1,34 @@ +// Biosample map: initialize Leaflet, load GeoJSON from the data-geo URL, and +// plot circle markers (no marker-image assets needed). +document.addEventListener("DOMContentLoaded", function () { + var el = document.getElementById("map"); + if (!el || typeof L === "undefined") return; + + var map = L.map("map").setView([20, 0], 2); + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 18, + attribution: "© OpenStreetMap contributors", + }).addTo(map); + + fetch(el.dataset.geo) + .then(function (r) { return r.json(); }) + .then(function (fc) { + var layer = L.geoJSON(fc, { + pointToLayer: function (_f, latlng) { + return L.circleMarker(latlng, { + radius: 6, color: "#0d6efd", weight: 1, fillOpacity: 0.7, + }); + }, + onEachFeature: function (f, l) { + var p = f.properties || {}; + l.bindPopup((p.accession || "—") + " (" + (p.source || "") + ")"); + }, + }).addTo(map); + + var count = (fc.features || []).length; + var badge = document.getElementById("map-count"); + if (badge) badge.textContent = count; + if (count > 0) map.fitBounds(layer.getBounds().pad(0.2)); + }) + .catch(function (e) { console.error("geo-data load failed", e); }); +}); diff --git a/rust/crates/du-web/assets/vendor/leaflet.css b/rust/crates/du-web/assets/vendor/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/rust/crates/du-web/assets/vendor/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/rust/crates/du-web/assets/vendor/leaflet.js b/rust/crates/du-web/assets/vendor/leaflet.js new file mode 100644 index 0000000..a3bf693 --- /dev/null +++ b/rust/crates/du-web/assets/vendor/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1 Router { + Router::new() + .route("/biosamples/map", get(map_page)) + .route("/biosamples/geo-data", get(geo_data)) +} + +#[derive(askama::Template)] +#[template(path = "biosamples/map.html")] +struct MapTemplate { + t: T, + next: String, +} + +async fn map_page(locale: Locale) -> Response { + html(&MapTemplate { t: locale.t, next: locale.next }) +} + +/// GeoJSON FeatureCollection of biosample locations for Leaflet. +async fn geo_data(State(st): State) -> Result, AppError> { + let points = du_db::biosample::geo_points(&st.pool).await?; + let features: Vec = points + .into_iter() + .map(|p| { + json!({ + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [p.lon, p.lat] }, + "properties": { "accession": p.accession, "source": p.source.label() }, + }) + }) + .collect(); + Ok(Json(json!({ "type": "FeatureCollection", "features": features }))) +} diff --git a/rust/crates/du-web/src/routes/mod.rs b/rust/crates/du-web/src/routes/mod.rs index 5aeb73d..783f19f 100644 --- a/rust/crates/du-web/src/routes/mod.rs +++ b/rust/crates/du-web/src/routes/mod.rs @@ -12,6 +12,7 @@ use axum::Router; use serde::Deserialize; use tower_http::services::ServeDir; +pub mod maps; pub mod references; pub mod tree; pub mod variants; @@ -33,6 +34,7 @@ pub fn app(state: AppState) -> Router { .merge(variants::router()) .merge(tree::router()) .merge(references::router()) + .merge(maps::router()) .nest_service("/assets", ServeDir::new(assets_dir())) .with_state(state) } diff --git a/rust/crates/du-web/templates/base.html b/rust/crates/du-web/templates/base.html index a2b183f..ed4025e 100644 --- a/rust/crates/du-web/templates/base.html +++ b/rust/crates/du-web/templates/base.html @@ -7,6 +7,7 @@ + {% block head %}{% endblock %} diff --git a/rust/crates/du-web/templates/curator/dashboard.html b/rust/crates/du-web/templates/curator/dashboard.html new file mode 100644 index 0000000..a5550bf --- /dev/null +++ b/rust/crates/du-web/templates/curator/dashboard.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}{{ t.get("curator.title") }} — {{ t.get("app.name") }}{% endblock %} +{% block content %} +

{{ t.get("curator.title") }}

+

{{ t.get("curator.welcome") }} {{ display_name }} · {{ t.get("curator.roles") }}: {{ roles }}

+ +{% endblock %} diff --git a/rust/crates/du-web/templates/curator/haplogroups/detail.html b/rust/crates/du-web/templates/curator/haplogroups/detail.html new file mode 100644 index 0000000..4139830 --- /dev/null +++ b/rust/crates/du-web/templates/curator/haplogroups/detail.html @@ -0,0 +1,25 @@ +{# Fragment: haplogroup detail + actions. Target of #hg-detail. #} +
+
+ {{ hg.name }} + {{ hg.dna }} +
+
+ {% if let Some(err) = error %}
{{ err }}
{% endif %} +
+
{{ t.get("hg.field.lineage") }}
{{ hg.lineage }}
+
{{ t.get("hg.field.source") }}
{{ hg.source }}
+
{{ t.get("hg.field.formed") }}
{{ hg.formed_ybp }}
+
{{ t.get("hg.field.tmrca") }}
{{ hg.tmrca_ybp }}
+
+
+ + {% if can_delete %} + + {% endif %} +
+
+
diff --git a/rust/crates/du-web/templates/curator/haplogroups/empty.html b/rust/crates/du-web/templates/curator/haplogroups/empty.html new file mode 100644 index 0000000..29ca6a1 --- /dev/null +++ b/rust/crates/du-web/templates/curator/haplogroups/empty.html @@ -0,0 +1,2 @@ +{# Shown in #hg-detail after a deletion. #} +

{{ t.get("hg.select") }}

diff --git a/rust/crates/du-web/templates/curator/haplogroups/form.html b/rust/crates/du-web/templates/curator/haplogroups/form.html new file mode 100644 index 0000000..0965a3a --- /dev/null +++ b/rust/crates/du-web/templates/curator/haplogroups/form.html @@ -0,0 +1,48 @@ +{# Fragment: create/edit form. Posts to {action}, swaps #hg-detail with the saved + panel; the server's HX-Trigger then reloads the list. Target of #hg-detail. #} +
+
{% if is_edit %}{{ t.get("hg.edit") }}{% else %}{{ t.get("hg.new") }}{% endif %}
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + {% if is_edit %} + + {% else %} + + {% endif %} +
+
+
+
diff --git a/rust/crates/du-web/templates/curator/haplogroups/list.html b/rust/crates/du-web/templates/curator/haplogroups/list.html new file mode 100644 index 0000000..09b7e94 --- /dev/null +++ b/rust/crates/du-web/templates/curator/haplogroups/list.html @@ -0,0 +1,30 @@ +{# Fragment: haplogroup rows + pager. Target of #hg-table. #} + + + + + + {% for r in list.rows %} + + + + + + {% else %} + + {% endfor %} + +
{{ t.get("hg.col.name") }}{{ t.get("hg.col.type") }}{{ t.get("hg.col.lineage") }}
{{ r.name }}{{ r.dna }}{{ r.lineage }}
{{ t.get("hg.none") }}
+{% if list.total_pages > 1 %} + +{% else %} +{{ list.total }} {{ t.get("pagination.total") }} +{% endif %} diff --git a/rust/crates/du-web/templates/curator/haplogroups/page.html b/rust/crates/du-web/templates/curator/haplogroups/page.html new file mode 100644 index 0000000..a1c83fd --- /dev/null +++ b/rust/crates/du-web/templates/curator/haplogroups/page.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}{{ t.get("hg.title") }} — {{ t.get("app.name") }}{% endblock %} +{% block content %} +
+

{{ t.get("hg.title") }}

+ +
+
+
+
+ + +
+ {# Reloads on load and whenever a mutation fires hg-changed on the body. #} +
+ {% include "curator/haplogroups/list.html" %} +
+
+
+
+

{{ t.get("hg.select") }}

+
+
+
+{% endblock %} diff --git a/rust/locales/en.txt b/rust/locales/en.txt index 56285d6..0d699d7 100644 --- a/rust/locales/en.txt +++ b/rust/locales/en.txt @@ -7,6 +7,9 @@ nav.variants=Variants nav.references=References nav.map=Map nav.coverage=Coverage +nav.curator=Curator +nav.login=Login +nav.logout=Logout lang.label=Language lang.en=English lang.es=Español @@ -78,3 +81,36 @@ coverage.col.meanDepth=Mean depth coverage.col.cov10x=Coverage ≥10× coverage.col.expectedDepth=Expected coverage.none=No coverage data yet. + +auth.login.title=Sign in +auth.login.handle=Handle or email +auth.login.password=Password +auth.login.submit=Sign in +auth.login.error=Invalid handle or password. + +curator.title=Curator Dashboard +curator.welcome=Signed in as +curator.roles=Roles +curator.tool.haplogroups=Haplogroups + +hg.title=Haplogroups +hg.new=New haplogroup +hg.search=Search by name… +hg.filter.all=All lineages +hg.col.name=Name +hg.col.type=Lineage +hg.col.lineage=Path +hg.none=No haplogroups match. +hg.select=Select a haplogroup, or create one. +hg.field.name=Name +hg.field.type=Lineage +hg.field.lineage=Path +hg.field.source=Source +hg.field.formed=Formed (ybp) +hg.field.tmrca=TMRCA (ybp) +hg.save=Save +hg.cancel=Cancel +hg.edit=Edit +hg.delete=Delete +hg.delete.confirm=Delete this haplogroup? +hg.deleteBlocked=Cannot delete: it still has tree relationships. diff --git a/rust/locales/es.txt b/rust/locales/es.txt index cb526ff..d92d9b9 100644 --- a/rust/locales/es.txt +++ b/rust/locales/es.txt @@ -7,6 +7,9 @@ nav.variants=Variantes nav.references=Referencias nav.map=Mapa nav.coverage=Cobertura +nav.curator=Curador +nav.login=Iniciar sesión +nav.logout=Cerrar sesión lang.label=Idioma lang.en=English lang.es=Español @@ -78,3 +81,36 @@ coverage.col.meanDepth=Profundidad media coverage.col.cov10x=Cobertura ≥10× coverage.col.expectedDepth=Esperada coverage.none=Aún no hay datos de cobertura. + +auth.login.title=Iniciar sesión +auth.login.handle=Usuario o correo +auth.login.password=Contraseña +auth.login.submit=Iniciar sesión +auth.login.error=Usuario o contraseña no válidos. + +curator.title=Panel del curador +curator.welcome=Sesión iniciada como +curator.roles=Roles +curator.tool.haplogroups=Haplogrupos + +hg.title=Haplogrupos +hg.new=Nuevo haplogrupo +hg.search=Buscar por nombre… +hg.filter.all=Todos los linajes +hg.col.name=Nombre +hg.col.type=Linaje +hg.col.lineage=Ruta +hg.none=No hay haplogrupos coincidentes. +hg.select=Seleccione un haplogrupo o cree uno. +hg.field.name=Nombre +hg.field.type=Linaje +hg.field.lineage=Ruta +hg.field.source=Fuente +hg.field.formed=Formado (ybp) +hg.field.tmrca=TMRCA (ybp) +hg.save=Guardar +hg.cancel=Cancelar +hg.edit=Editar +hg.delete=Eliminar +hg.delete.confirm=¿Eliminar este haplogrupo? +hg.deleteBlocked=No se puede eliminar: aún tiene relaciones en el árbol. diff --git a/rust/locales/fr.txt b/rust/locales/fr.txt index 69810d8..bce5705 100644 --- a/rust/locales/fr.txt +++ b/rust/locales/fr.txt @@ -7,6 +7,9 @@ nav.variants=Variants nav.references=Références nav.map=Carte nav.coverage=Couverture +nav.curator=Curateur +nav.login=Connexion +nav.logout=Déconnexion lang.label=Langue lang.en=English lang.es=Español @@ -78,3 +81,36 @@ coverage.col.meanDepth=Profondeur moyenne coverage.col.cov10x=Couverture ≥10× coverage.col.expectedDepth=Attendue coverage.none=Aucune donnée de couverture pour le moment. + +auth.login.title=Connexion +auth.login.handle=Identifiant ou e-mail +auth.login.password=Mot de passe +auth.login.submit=Se connecter +auth.login.error=Identifiant ou mot de passe invalide. + +curator.title=Tableau de bord du curateur +curator.welcome=Connecté en tant que +curator.roles=Rôles +curator.tool.haplogroups=Haplogroupes + +hg.title=Haplogroupes +hg.new=Nouvel haplogroupe +hg.search=Rechercher par nom… +hg.filter.all=Toutes les lignées +hg.col.name=Nom +hg.col.type=Lignée +hg.col.lineage=Chemin +hg.none=Aucun haplogroupe correspondant. +hg.select=Sélectionnez un haplogroupe ou créez-en un. +hg.field.name=Nom +hg.field.type=Lignée +hg.field.lineage=Chemin +hg.field.source=Source +hg.field.formed=Formé (ybp) +hg.field.tmrca=TMRCA (ybp) +hg.save=Enregistrer +hg.cancel=Annuler +hg.edit=Modifier +hg.delete=Supprimer +hg.delete.confirm=Supprimer cet haplogroupe ? +hg.deleteBlocked=Suppression impossible : des relations d’arbre existent encore. From 44411842781c8c24698189d80b45448e0796307c Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 09:51:02 -0500 Subject: [PATCH 009/191] feat(rust): extend curator to variants + genome regions Two more curator surfaces on the established two-panel HTMX write-flow. Variants: - du-db variant: create/update/delete + is_referenced (guards delete when the variant defines a current haplogroup association). - Curator variant CRUD editing scalar fields + alias lists (common_names/rs_ids as comma-separated, stored into the aliases JSONB); coordinates preserved. Genome regions: - du-domain GenomeRegion; du-db genome_region list/get/create/update/delete. - Curator region CRUD with JSON textareas for the coordinates/properties JSONB documents, parse-validated: invalid JSON re-renders the form with an error and no HX-Trigger (so the list does not reload on failure). - Mutations fire distinct HX-Trigger events (variant-changed / region-changed); dashboard links to all three tools; i18n var.*/region.* across en/es/fr (the catalog-coverage test enforces es/fr parity). Verified live (as the seeded curator): variant create stores aliases JSONB + trigger + delete; region create stores coordinates JSONB, invalid-JSON error path, list + delete. Workspace 11/11 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/du-db/src/genome_region.rs | 125 +++++++ rust/crates/du-db/src/lib.rs | 1 + rust/crates/du-db/src/variant.rs | 73 +++- rust/crates/du-domain/src/genome_region.rs | 14 + rust/crates/du-domain/src/lib.rs | 1 + .../du-web/src/routes/curator_regions.rs | 301 ++++++++++++++++ .../du-web/src/routes/curator_variants.rs | 322 ++++++++++++++++++ rust/crates/du-web/src/routes/mod.rs | 4 + .../du-web/templates/curator/dashboard.html | 6 + .../templates/curator/regions/detail.html | 18 + .../templates/curator/regions/empty.html | 1 + .../templates/curator/regions/form.html | 35 ++ .../templates/curator/regions/list.html | 26 ++ .../templates/curator/regions/page.html | 27 ++ .../templates/curator/variants/detail.html | 23 ++ .../templates/curator/variants/empty.html | 1 + .../templates/curator/variants/form.html | 49 +++ .../templates/curator/variants/list.html | 26 ++ .../templates/curator/variants/page.html | 27 ++ rust/locales/en.txt | 41 +++ rust/locales/es.txt | 41 +++ rust/locales/fr.txt | 41 +++ 22 files changed, 1202 insertions(+), 1 deletion(-) create mode 100644 rust/crates/du-db/src/genome_region.rs create mode 100644 rust/crates/du-domain/src/genome_region.rs create mode 100644 rust/crates/du-web/src/routes/curator_regions.rs create mode 100644 rust/crates/du-web/src/routes/curator_variants.rs create mode 100644 rust/crates/du-web/templates/curator/regions/detail.html create mode 100644 rust/crates/du-web/templates/curator/regions/empty.html create mode 100644 rust/crates/du-web/templates/curator/regions/form.html create mode 100644 rust/crates/du-web/templates/curator/regions/list.html create mode 100644 rust/crates/du-web/templates/curator/regions/page.html create mode 100644 rust/crates/du-web/templates/curator/variants/detail.html create mode 100644 rust/crates/du-web/templates/curator/variants/empty.html create mode 100644 rust/crates/du-web/templates/curator/variants/form.html create mode 100644 rust/crates/du-web/templates/curator/variants/list.html create mode 100644 rust/crates/du-web/templates/curator/variants/page.html diff --git a/rust/crates/du-db/src/genome_region.rs b/rust/crates/du-db/src/genome_region.rs new file mode 100644 index 0000000..71b7472 --- /dev/null +++ b/rust/crates/du-db/src/genome_region.rs @@ -0,0 +1,125 @@ +//! Queries for `core.genome_region` (curator-managed multi-build regions). + +use crate::{DbError, Page}; +use du_domain::genome_region::GenomeRegion; +use sqlx::PgPool; + +#[derive(sqlx::FromRow)] +struct RegionRow { + id: i64, + region_type: String, + name: String, + coordinates: serde_json::Value, + properties: serde_json::Value, +} + +impl From for GenomeRegion { + fn from(r: RegionRow) -> Self { + GenomeRegion { + id: r.id, + region_type: r.region_type, + name: r.name, + coordinates: r.coordinates, + properties: r.properties, + } + } +} + +const SELECT: &str = "SELECT id, region_type, name, coordinates, properties FROM core.genome_region"; + +pub async fn get_by_id(pool: &PgPool, id: i64) -> Result, DbError> { + let row: Option = sqlx::query_as(&format!("{SELECT} WHERE id = $1")) + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row.map(Into::into)) +} + +/// Paginated list, optionally filtered by name/type substring and region type. +pub async fn list_paginated( + pool: &PgPool, + query: Option<&str>, + region_type: Option<&str>, + page: i64, + page_size: i64, +) -> Result, DbError> { + let offset = Page::<()>::offset(page, page_size); + let limit = page_size.clamp(1, 200); + let like = query.map(str::trim).filter(|q| !q.is_empty()).map(|q| format!("%{q}%")); + let rtype = region_type.map(str::trim).filter(|q| !q.is_empty()).map(str::to_string); + + let where_sql = "WHERE ($1::text IS NULL OR name ILIKE $1 OR region_type ILIKE $1) \ + AND ($2::text IS NULL OR region_type = $2)"; + + let total: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM core.genome_region {where_sql}")) + .bind(&like) + .bind(&rtype) + .fetch_one(pool) + .await?; + let rows: Vec = + sqlx::query_as(&format!("{SELECT} {where_sql} ORDER BY region_type, name LIMIT $3 OFFSET $4")) + .bind(&like) + .bind(&rtype) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(Page { + items: rows.into_iter().map(Into::into).collect(), + total, + page: page.max(1), + page_size: limit, + }) +} + +pub async fn create( + pool: &PgPool, + region_type: &str, + name: &str, + coordinates: &serde_json::Value, + properties: &serde_json::Value, +) -> Result { + let id: i64 = sqlx::query_scalar( + "INSERT INTO core.genome_region (region_type, name, coordinates, properties) \ + VALUES ($1, $2, $3, $4) RETURNING id", + ) + .bind(region_type) + .bind(name) + .bind(coordinates) + .bind(properties) + .fetch_one(pool) + .await?; + Ok(id) +} + +pub async fn update( + pool: &PgPool, + id: i64, + region_type: &str, + name: &str, + coordinates: &serde_json::Value, + properties: &serde_json::Value, +) -> Result { + let affected = sqlx::query( + "UPDATE core.genome_region SET region_type=$2, name=$3, coordinates=$4, properties=$5, updated_at=now() WHERE id=$1", + ) + .bind(id) + .bind(region_type) + .bind(name) + .bind(coordinates) + .bind(properties) + .execute(pool) + .await? + .rows_affected(); + Ok(affected > 0) +} + +pub async fn delete(pool: &PgPool, id: i64) -> Result { + let affected = sqlx::query("DELETE FROM core.genome_region WHERE id=$1") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + Ok(affected > 0) +} diff --git a/rust/crates/du-db/src/lib.rs b/rust/crates/du-db/src/lib.rs index b50e635..7e6ad3d 100644 --- a/rust/crates/du-db/src/lib.rs +++ b/rust/crates/du-db/src/lib.rs @@ -12,6 +12,7 @@ use thiserror::Error; pub mod auth; pub mod biosample; pub mod coverage; +pub mod genome_region; pub mod haplogroup; pub mod pagination; pub mod publication; diff --git a/rust/crates/du-db/src/variant.rs b/rust/crates/du-db/src/variant.rs index 529ba24..1c42ce4 100644 --- a/rust/crates/du-db/src/variant.rs +++ b/rust/crates/du-db/src/variant.rs @@ -2,7 +2,8 @@ //! enum columns are fetched as `::text` and parsed via serde; JSONB columns are //! read through `sqlx::types::Json` into the du-domain payload structs. -use crate::{parse_pg_enum, DbError, Page}; +use crate::{parse_pg_enum, pg_enum_label, DbError, Page}; +use du_domain::enums::{MutationType, NamingStatus}; use du_domain::ids::VariantId; use du_domain::variant::{Aliases, Annotations, Coordinates, Variant}; use sqlx::types::Json; @@ -44,6 +45,76 @@ pub async fn get_by_id(pool: &PgPool, id: VariantId) -> Result, row.map(VariantRow::into_domain).transpose() } +/// Create a variant (scalar fields + aliases; coordinates/annotations default +/// empty and are managed elsewhere). Returns the new id. +pub async fn create( + pool: &PgPool, + canonical_name: &str, + mutation_type: MutationType, + naming_status: NamingStatus, + aliases: &Aliases, +) -> Result { + let aliases_json = serde_json::to_value(aliases).map_err(|e| DbError::Decode(e.to_string()))?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO core.variant (canonical_name, mutation_type, naming_status, aliases) \ + VALUES ($1, $2::core.mutation_type, $3::core.naming_status, $4) RETURNING id", + ) + .bind(canonical_name) + .bind(pg_enum_label(&mutation_type)?) + .bind(pg_enum_label(&naming_status)?) + .bind(aliases_json) + .fetch_one(pool) + .await?; + Ok(VariantId(id)) +} + +/// Update a variant's scalar fields + aliases. Coordinates and annotations are +/// left untouched. Returns whether a row was affected. +pub async fn update( + pool: &PgPool, + id: VariantId, + canonical_name: &str, + mutation_type: MutationType, + naming_status: NamingStatus, + aliases: &Aliases, +) -> Result { + let aliases_json = serde_json::to_value(aliases).map_err(|e| DbError::Decode(e.to_string()))?; + let affected = sqlx::query( + "UPDATE core.variant SET canonical_name=$2, mutation_type=$3::core.mutation_type, \ + naming_status=$4::core.naming_status, aliases=$5, updated_at=now() WHERE id=$1", + ) + .bind(id.0) + .bind(canonical_name) + .bind(pg_enum_label(&mutation_type)?) + .bind(pg_enum_label(&naming_status)?) + .bind(aliases_json) + .execute(pool) + .await? + .rows_affected(); + Ok(affected > 0) +} + +/// Whether the variant is referenced by a current haplogroup association +/// (a guard before deletion). +pub async fn is_referenced(pool: &PgPool, id: VariantId) -> Result { + let n: i64 = sqlx::query_scalar( + "SELECT count(*) FROM tree.haplogroup_variant WHERE variant_id = $1 AND valid_until IS NULL", + ) + .bind(id.0) + .fetch_one(pool) + .await?; + Ok(n > 0) +} + +pub async fn delete(pool: &PgPool, id: VariantId) -> Result { + let affected = sqlx::query("DELETE FROM core.variant WHERE id=$1") + .bind(id.0) + .execute(pool) + .await? + .rows_affected(); + Ok(affected > 0) +} + /// Paginated search by canonical name OR any alias in the `common_names`/`rs_ids` /// JSONB arrays (the public variant browser). `query = None`/empty lists all. pub async fn search( diff --git a/rust/crates/du-domain/src/genome_region.rs b/rust/crates/du-domain/src/genome_region.rs new file mode 100644 index 0000000..7d43757 --- /dev/null +++ b/rust/crates/du-domain/src/genome_region.rs @@ -0,0 +1,14 @@ +//! Genome region domain type — multi-build structural regions (centromere, +//! telomere, PAR, …). Coordinates and properties are JSONB documents keyed by +//! reference build, e.g. `{ "GRCh38": {contig, start, end}, "hs1": {...} }`. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenomeRegion { + pub id: i64, + pub region_type: String, + pub name: String, + pub coordinates: serde_json::Value, + pub properties: serde_json::Value, +} diff --git a/rust/crates/du-domain/src/lib.rs b/rust/crates/du-domain/src/lib.rs index e3d130f..ab9a428 100644 --- a/rust/crates/du-domain/src/lib.rs +++ b/rust/crates/du-domain/src/lib.rs @@ -9,6 +9,7 @@ pub mod biosample; pub mod coverage; pub mod enums; pub mod error; +pub mod genome_region; pub mod haplogroup; pub mod ids; pub mod publication; diff --git a/rust/crates/du-web/src/routes/curator_regions.rs b/rust/crates/du-web/src/routes/curator_regions.rs new file mode 100644 index 0000000..ed55f38 --- /dev/null +++ b/rust/crates/du-web/src/routes/curator_regions.rs @@ -0,0 +1,301 @@ +//! Curator genome-region CRUD. The multi-build `coordinates` and `properties` +//! are JSONB documents, edited here as JSON textareas (parse-validated on save, +//! re-rendering the form with an error on invalid JSON). + +use crate::auth::Curator; +use crate::error::AppError; +use crate::htmx::HxHeaders; +use crate::i18n::{Locale, T}; +use crate::render::html; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Form, Router}; +use serde::Deserialize; + +const CHANGED: &str = "region-changed"; + +pub fn router() -> Router { + Router::new() + .route("/curator/regions", get(page)) + .route("/curator/regions/fragment", get(list)) + .route("/curator/regions/new", get(new_form)) + .route("/curator/regions", post(create)) + .route("/curator/regions/:id/panel", get(panel)) + .route("/curator/regions/:id/edit", get(edit_form)) + .route("/curator/regions/:id", post(update)) + .route("/curator/regions/:id", axum::routing::delete(remove)) +} + +#[derive(Deserialize)] +struct ListQuery { + query: Option, + page: Option, +} + +struct Row { + id: i64, + region_type: String, + name: String, + builds: String, +} +struct ListView { + query: String, + rows: Vec, + page: i64, + total: i64, + total_pages: i64, +} + +/// Top-level keys of a JSONB object, comma-joined (the build labels). +fn keys_of(v: &serde_json::Value) -> String { + v.as_object() + .map(|o| o.keys().cloned().collect::>().join(", ")) + .unwrap_or_default() +} + +async fn load_list(st: &AppState, q: &ListQuery) -> Result { + let result = + du_db::genome_region::list_paginated(&st.pool, q.query.as_deref(), None, q.page.unwrap_or(1), 20).await?; + Ok(ListView { + query: q.query.clone().unwrap_or_default(), + rows: result + .items + .iter() + .map(|r| Row { + id: r.id, + region_type: r.region_type.clone(), + name: r.name.clone(), + builds: keys_of(&r.coordinates), + }) + .collect(), + page: result.page, + total: result.total, + total_pages: result.total_pages(), + }) +} + +#[derive(askama::Template)] +#[template(path = "curator/regions/page.html")] +struct PageTemplate { + t: T, + next: String, + user: Option, + list: ListView, +} +#[derive(askama::Template)] +#[template(path = "curator/regions/list.html")] +struct ListTemplate { + t: T, + list: ListView, +} + +async fn page( + Curator(s): Curator, + State(st): State, + locale: Locale, + Query(q): Query, +) -> Result { + let list = load_list(&st, &q).await?; + Ok(html(&PageTemplate { + t: locale.t, + next: locale.next, + user: Some(crate::auth::NavUser { display_name: s.display_name, is_curator: true }), + list, + })) +} + +async fn list( + _c: Curator, + State(st): State, + locale: Locale, + Query(q): Query, +) -> Result { + let list = load_list(&st, &q).await?; + Ok(html(&ListTemplate { t: locale.t, list })) +} + +#[derive(askama::Template)] +#[template(path = "curator/regions/detail.html")] +struct DetailTemplate { + t: T, + id: i64, + region_type: String, + name: String, + coordinates: String, + properties: String, +} + +fn pretty(v: &serde_json::Value) -> String { + serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".into()) +} + +async fn detail(st: &AppState, t: T, id: i64) -> Result { + let r = du_db::genome_region::get_by_id(&st.pool, id) + .await? + .ok_or_else(|| AppError::NotFound(format!("region {id}")))?; + Ok(html(&DetailTemplate { + t, + id: r.id, + region_type: r.region_type, + name: r.name, + coordinates: pretty(&r.coordinates), + properties: pretty(&r.properties), + })) +} + +async fn panel( + _c: Curator, + State(st): State, + locale: Locale, + Path(id): Path, +) -> Result { + detail(&st, locale.t, id).await +} + +#[derive(askama::Template)] +#[template(path = "curator/regions/form.html")] +struct FormTemplate { + t: T, + action: String, + is_edit: bool, + id: i64, + region_type: String, + name: String, + coordinates: String, + properties: String, + error: Option, +} + +async fn new_form(_c: Curator, locale: Locale) -> Response { + html(&FormTemplate { + t: locale.t, + action: "/curator/regions".into(), + is_edit: false, + id: 0, + region_type: String::new(), + name: String::new(), + coordinates: "{\n \"GRCh38\": { \"contig\": \"chr1\", \"start\": 0, \"end\": 0 }\n}".into(), + properties: "{}".into(), + error: None, + }) +} + +async fn edit_form( + _c: Curator, + State(st): State, + locale: Locale, + Path(id): Path, +) -> Result { + let r = du_db::genome_region::get_by_id(&st.pool, id) + .await? + .ok_or_else(|| AppError::NotFound(format!("region {id}")))?; + Ok(html(&FormTemplate { + t: locale.t, + action: format!("/curator/regions/{id}"), + is_edit: true, + id, + region_type: r.region_type, + name: r.name, + coordinates: pretty(&r.coordinates), + properties: pretty(&r.properties), + error: None, + })) +} + +#[derive(Deserialize)] +struct RegionForm { + region_type: String, + name: String, + coordinates: String, + properties: String, +} + +fn changed(body: Response) -> Response { + (HxHeaders::new().trigger(CHANGED), body).into_response() +} + +/// Parse the two JSON fields; on failure re-render the form with an error. +fn parse_json( + t: T, + action: String, + is_edit: bool, + id: i64, + f: &RegionForm, +) -> Result<(serde_json::Value, serde_json::Value), Response> { + let coords = serde_json::from_str::(&f.coordinates); + let props = serde_json::from_str::(&f.properties); + match (coords, props) { + (Ok(c), Ok(p)) => Ok((c, p)), + (c, p) => { + let mut msg = String::new(); + if let Err(e) = &c { + msg = format!("coordinates: {e}"); + } else if let Err(e) = &p { + msg = format!("properties: {e}"); + } + Err(html(&FormTemplate { + t, + action, + is_edit, + id, + region_type: f.region_type.clone(), + name: f.name.clone(), + coordinates: f.coordinates.clone(), + properties: f.properties.clone(), + error: Some(msg), + })) + } + } +} + +async fn create( + _c: Curator, + State(st): State, + locale: Locale, + Form(f): Form, +) -> Result { + if f.name.trim().is_empty() || f.region_type.trim().is_empty() { + return Err(AppError::BadRequest("region_type and name are required".into())); + } + let (coords, props) = match parse_json(locale.t, "/curator/regions".into(), false, 0, &f) { + Ok(v) => v, + Err(resp) => return Ok(resp), + }; + let id = du_db::genome_region::create(&st.pool, f.region_type.trim(), f.name.trim(), &coords, &props).await?; + Ok(changed(detail(&st, locale.t, id).await?)) +} + +async fn update( + _c: Curator, + State(st): State, + locale: Locale, + Path(id): Path, + Form(f): Form, +) -> Result { + if f.name.trim().is_empty() || f.region_type.trim().is_empty() { + return Err(AppError::BadRequest("region_type and name are required".into())); + } + let (coords, props) = match parse_json(locale.t, format!("/curator/regions/{id}"), true, id, &f) { + Ok(v) => v, + Err(resp) => return Ok(resp), + }; + du_db::genome_region::update(&st.pool, id, f.region_type.trim(), f.name.trim(), &coords, &props).await?; + Ok(changed(detail(&st, locale.t, id).await?)) +} + +async fn remove( + _c: Curator, + State(st): State, + locale: Locale, + Path(id): Path, +) -> Result { + du_db::genome_region::delete(&st.pool, id).await?; + #[derive(askama::Template)] + #[template(path = "curator/regions/empty.html")] + struct Empty { + t: T, + } + Ok(changed(html(&Empty { t: locale.t }))) +} diff --git a/rust/crates/du-web/src/routes/curator_variants.rs b/rust/crates/du-web/src/routes/curator_variants.rs new file mode 100644 index 0000000..aadbfb9 --- /dev/null +++ b/rust/crates/du-web/src/routes/curator_variants.rs @@ -0,0 +1,322 @@ +//! Curator variant CRUD. Same HTMX two-panel write-flow as haplogroups; edits +//! the scalar fields + alias lists (common_names / rs_ids). Coordinate editing +//! is out of scope for this panel and is preserved across updates. + +use crate::auth::Curator; +use crate::error::AppError; +use crate::htmx::HxHeaders; +use crate::i18n::{Locale, T}; +use crate::render::html; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Form, Router}; +use du_domain::enums::{MutationType, NamingStatus}; +use du_domain::ids::VariantId; +use du_domain::variant::Aliases; +use serde::Deserialize; + +const CHANGED: &str = "variant-changed"; + +pub fn router() -> Router { + Router::new() + .route("/curator/variants", get(page)) + .route("/curator/variants/fragment", get(list)) + .route("/curator/variants/new", get(new_form)) + .route("/curator/variants", post(create)) + .route("/curator/variants/:id/panel", get(panel)) + .route("/curator/variants/:id/edit", get(edit_form)) + .route("/curator/variants/:id", post(update)) + .route("/curator/variants/:id", axum::routing::delete(remove)) +} + +fn parse_mutation(s: &str) -> MutationType { + match s { + "INDEL" => MutationType::Indel, + "STR" => MutationType::Str, + "DEL" => MutationType::Del, + "INS" => MutationType::Ins, + "MNP" => MutationType::Mnp, + _ => MutationType::Snp, + } +} +fn parse_naming(s: &str) -> NamingStatus { + match s { + "NAMED" => NamingStatus::Named, + "PENDING_REVIEW" => NamingStatus::PendingReview, + _ => NamingStatus::Unnamed, + } +} +/// "a, b ,c" -> ["a","b","c"] +fn csv(s: Option) -> Vec { + s.unwrap_or_default() + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect() +} + +#[derive(Deserialize)] +struct ListQuery { + query: Option, + page: Option, +} + +struct Row { + id: i64, + name: String, + mutation_type: String, + naming_status: String, +} +struct ListView { + query: String, + rows: Vec, + page: i64, + total: i64, + total_pages: i64, +} + +async fn load_list(st: &AppState, q: &ListQuery) -> Result { + let result = du_db::variant::search(&st.pool, q.query.as_deref(), q.page.unwrap_or(1), 20).await?; + Ok(ListView { + query: q.query.clone().unwrap_or_default(), + rows: result + .items + .iter() + .map(|v| Row { + id: v.id.0, + name: v.canonical_name.clone(), + mutation_type: v.mutation_type.label().to_string(), + naming_status: v.naming_status.label().to_string(), + }) + .collect(), + page: result.page, + total: result.total, + total_pages: result.total_pages(), + }) +} + +#[derive(askama::Template)] +#[template(path = "curator/variants/page.html")] +struct PageTemplate { + t: T, + next: String, + user: Option, + list: ListView, +} + +#[derive(askama::Template)] +#[template(path = "curator/variants/list.html")] +struct ListTemplate { + t: T, + list: ListView, +} + +async fn page( + Curator(s): Curator, + State(st): State, + locale: Locale, + Query(q): Query, +) -> Result { + let list = load_list(&st, &q).await?; + Ok(html(&PageTemplate { + t: locale.t, + next: locale.next, + user: Some(crate::auth::NavUser { display_name: s.display_name, is_curator: true }), + list, + })) +} + +async fn list( + _c: Curator, + State(st): State, + locale: Locale, + Query(q): Query, +) -> Result { + let list = load_list(&st, &q).await?; + Ok(html(&ListTemplate { t: locale.t, list })) +} + +struct DetailView { + id: i64, + name: String, + mutation_type: String, + naming_status: String, + common_names: String, + rs_ids: String, + builds: String, +} + +#[derive(askama::Template)] +#[template(path = "curator/variants/detail.html")] +struct DetailTemplate { + t: T, + v: DetailView, + can_delete: bool, + error: Option, +} + +async fn detail_view(st: &AppState, id: VariantId) -> Result { + let v = du_db::variant::get_by_id(&st.pool, id) + .await? + .ok_or_else(|| AppError::NotFound(format!("variant {}", id.0)))?; + let mut builds: Vec<&str> = v.coordinates.0.keys().map(String::as_str).collect(); + builds.sort_unstable(); + Ok(DetailView { + id: v.id.0, + name: v.canonical_name, + mutation_type: v.mutation_type.label().to_string(), + naming_status: v.naming_status.label().to_string(), + common_names: v.aliases.common_names.join(", "), + rs_ids: v.aliases.rs_ids.join(", "), + builds: builds.join(", "), + }) +} + +async fn render_detail(st: &AppState, t: T, id: VariantId, error: Option) -> Result { + let v = detail_view(st, id).await?; + let can_delete = !du_db::variant::is_referenced(&st.pool, id).await?; + Ok(html(&DetailTemplate { t, v, can_delete, error })) +} + +async fn panel( + _c: Curator, + State(st): State, + locale: Locale, + Path(id): Path, +) -> Result { + render_detail(&st, locale.t, VariantId(id), None).await +} + +#[derive(askama::Template)] +#[template(path = "curator/variants/form.html")] +struct FormTemplate { + t: T, + action: String, + is_edit: bool, + id: i64, + name: String, + mutation_type: String, + naming_status: String, + common_names: String, + rs_ids: String, +} + +async fn new_form(_c: Curator, locale: Locale) -> Response { + html(&FormTemplate { + t: locale.t, + action: "/curator/variants".into(), + is_edit: false, + id: 0, + name: String::new(), + mutation_type: "SNP".into(), + naming_status: "UNNAMED".into(), + common_names: String::new(), + rs_ids: String::new(), + }) +} + +async fn edit_form( + _c: Curator, + State(st): State, + locale: Locale, + Path(id): Path, +) -> Result { + let d = detail_view(&st, VariantId(id)).await?; + Ok(html(&FormTemplate { + t: locale.t, + action: format!("/curator/variants/{id}"), + is_edit: true, + id, + name: d.name, + mutation_type: d.mutation_type, + naming_status: d.naming_status, + common_names: d.common_names, + rs_ids: d.rs_ids, + })) +} + +#[derive(Deserialize)] +struct VariantForm { + name: String, + mutation_type: Option, + naming_status: Option, + common_names: Option, + rs_ids: Option, +} + +fn aliases_from(form: &VariantForm) -> Aliases { + Aliases { + common_names: csv(form.common_names.clone()), + rs_ids: csv(form.rs_ids.clone()), + ..Default::default() + } +} + +fn changed(body: Response) -> Response { + (HxHeaders::new().trigger(CHANGED), body).into_response() +} + +async fn create( + _c: Curator, + State(st): State, + locale: Locale, + Form(f): Form, +) -> Result { + if f.name.trim().is_empty() { + return Err(AppError::BadRequest("name is required".into())); + } + let id = du_db::variant::create( + &st.pool, + f.name.trim(), + parse_mutation(f.mutation_type.as_deref().unwrap_or("SNP")), + parse_naming(f.naming_status.as_deref().unwrap_or("UNNAMED")), + &aliases_from(&f), + ) + .await?; + Ok(changed(render_detail(&st, locale.t, id, None).await?)) +} + +async fn update( + _c: Curator, + State(st): State, + locale: Locale, + Path(id): Path, + Form(f): Form, +) -> Result { + if f.name.trim().is_empty() { + return Err(AppError::BadRequest("name is required".into())); + } + du_db::variant::update( + &st.pool, + VariantId(id), + f.name.trim(), + parse_mutation(f.mutation_type.as_deref().unwrap_or("SNP")), + parse_naming(f.naming_status.as_deref().unwrap_or("UNNAMED")), + &aliases_from(&f), + ) + .await?; + Ok(changed(render_detail(&st, locale.t, VariantId(id), None).await?)) +} + +async fn remove( + _c: Curator, + State(st): State, + locale: Locale, + Path(id): Path, +) -> Result { + let vid = VariantId(id); + if du_db::variant::is_referenced(&st.pool, vid).await? { + let msg = locale.t.get("var.deleteBlocked").to_string(); + return render_detail(&st, locale.t, vid, Some(msg)).await; + } + du_db::variant::delete(&st.pool, vid).await?; + + #[derive(askama::Template)] + #[template(path = "curator/variants/empty.html")] + struct Empty { + t: T, + } + Ok(changed(html(&Empty { t: locale.t }))) +} diff --git a/rust/crates/du-web/src/routes/mod.rs b/rust/crates/du-web/src/routes/mod.rs index 77fa017..8294f3b 100644 --- a/rust/crates/du-web/src/routes/mod.rs +++ b/rust/crates/du-web/src/routes/mod.rs @@ -17,6 +17,8 @@ use tower_http::services::ServeDir; pub mod auth_routes; pub mod coverage; pub mod curator; +pub mod curator_regions; +pub mod curator_variants; pub mod maps; pub mod references; pub mod tree; @@ -43,6 +45,8 @@ pub fn app(state: AppState) -> Router { .merge(coverage::router()) .merge(auth_routes::router()) .merge(curator::router()) + .merge(curator_variants::router()) + .merge(curator_regions::router()) .nest_service("/assets", ServeDir::new(assets_dir())) .layer(CookieManagerLayer::new()) .with_state(state) diff --git a/rust/crates/du-web/templates/curator/dashboard.html b/rust/crates/du-web/templates/curator/dashboard.html index a5550bf..fa3864c 100644 --- a/rust/crates/du-web/templates/curator/dashboard.html +++ b/rust/crates/du-web/templates/curator/dashboard.html @@ -7,5 +7,11 @@

{{ t.get("curator.title") }}

{{ t.get("curator.tool.haplogroups") }} + + {{ t.get("curator.tool.variants") }} + + + {{ t.get("curator.tool.regions") }} + {% endblock %} diff --git a/rust/crates/du-web/templates/curator/regions/detail.html b/rust/crates/du-web/templates/curator/regions/detail.html new file mode 100644 index 0000000..fa752a1 --- /dev/null +++ b/rust/crates/du-web/templates/curator/regions/detail.html @@ -0,0 +1,18 @@ +{# Fragment: region detail + actions. Target of #region-detail. #} +
+
+ {{ name }} + {{ region_type }} +
+
+
{{ t.get("region.field.coordinates") }}
+
{{ coordinates }}
+
{{ t.get("region.field.properties") }}
+
{{ properties }}
+
+ + +
+
+
diff --git a/rust/crates/du-web/templates/curator/regions/empty.html b/rust/crates/du-web/templates/curator/regions/empty.html new file mode 100644 index 0000000..6401f8c --- /dev/null +++ b/rust/crates/du-web/templates/curator/regions/empty.html @@ -0,0 +1 @@ +

{{ t.get("region.select") }}

diff --git a/rust/crates/du-web/templates/curator/regions/form.html b/rust/crates/du-web/templates/curator/regions/form.html new file mode 100644 index 0000000..51da44b --- /dev/null +++ b/rust/crates/du-web/templates/curator/regions/form.html @@ -0,0 +1,35 @@ +{# Fragment: region create/edit form with JSON textareas. Target of #region-detail. #} +
+
{% if is_edit %}{{ t.get("region.edit") }}{% else %}{{ t.get("region.new") }}{% endif %}
+
+ {% if let Some(err) = error %}
{{ err }}
{% endif %} +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + {% if is_edit %} + + {% else %} + + {% endif %} +
+
+
+
diff --git a/rust/crates/du-web/templates/curator/regions/list.html b/rust/crates/du-web/templates/curator/regions/list.html new file mode 100644 index 0000000..e6a30d6 --- /dev/null +++ b/rust/crates/du-web/templates/curator/regions/list.html @@ -0,0 +1,26 @@ +{# Fragment: region rows + pager. Target of #region-table. #} + + + + {% for r in list.rows %} + + + + + + {% else %} + + {% endfor %} + +
{{ t.get("region.col.type") }}{{ t.get("region.col.name") }}{{ t.get("region.col.builds") }}
{{ r.region_type }}{{ r.name }}{{ r.builds }}
{{ t.get("region.none") }}
+{% if list.total_pages > 1 %} + +{% else %} +{{ list.total }} {{ t.get("pagination.total") }} +{% endif %} diff --git a/rust/crates/du-web/templates/curator/regions/page.html b/rust/crates/du-web/templates/curator/regions/page.html new file mode 100644 index 0000000..903e19f --- /dev/null +++ b/rust/crates/du-web/templates/curator/regions/page.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}{{ t.get("region.title") }} — {{ t.get("app.name") }}{% endblock %} +{% block content %} +
+

{{ t.get("region.title") }}

+ +
+
+
+ +
+ {% include "curator/regions/list.html" %} +
+
+
+

{{ t.get("region.select") }}

+
+
+{% endblock %} diff --git a/rust/crates/du-web/templates/curator/variants/detail.html b/rust/crates/du-web/templates/curator/variants/detail.html new file mode 100644 index 0000000..1e27615 --- /dev/null +++ b/rust/crates/du-web/templates/curator/variants/detail.html @@ -0,0 +1,23 @@ +{# Fragment: variant detail + actions. Target of #variant-detail. #} +
+
+ {{ v.name }} + {{ v.mutation_type }} +
+
+ {% if let Some(err) = error %}
{{ err }}
{% endif %} +
+
{{ t.get("var.field.status") }}
{{ v.naming_status }}
+
{{ t.get("var.field.commonNames") }}
{{ v.common_names }}
+
{{ t.get("var.field.rsIds") }}
{{ v.rs_ids }}
+
{{ t.get("var.field.builds") }}
{{ v.builds }}
+
+
+ + {% if can_delete %} + + {% endif %} +
+
+
diff --git a/rust/crates/du-web/templates/curator/variants/empty.html b/rust/crates/du-web/templates/curator/variants/empty.html new file mode 100644 index 0000000..7e3fd92 --- /dev/null +++ b/rust/crates/du-web/templates/curator/variants/empty.html @@ -0,0 +1 @@ +

{{ t.get("var.select") }}

diff --git a/rust/crates/du-web/templates/curator/variants/form.html b/rust/crates/du-web/templates/curator/variants/form.html new file mode 100644 index 0000000..641325c --- /dev/null +++ b/rust/crates/du-web/templates/curator/variants/form.html @@ -0,0 +1,49 @@ +{# Fragment: variant create/edit form. Target of #variant-detail. #} +
+
{% if is_edit %}{{ t.get("var.edit") }}{% else %}{{ t.get("var.new") }}{% endif %}
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + {% if is_edit %} + + {% else %} + + {% endif %} +
+
+
+
diff --git a/rust/crates/du-web/templates/curator/variants/list.html b/rust/crates/du-web/templates/curator/variants/list.html new file mode 100644 index 0000000..333cc2a --- /dev/null +++ b/rust/crates/du-web/templates/curator/variants/list.html @@ -0,0 +1,26 @@ +{# Fragment: variant rows + pager. Target of #variant-table. #} + + + + {% for r in list.rows %} + + + + + + {% else %} + + {% endfor %} + +
{{ t.get("var.col.name") }}{{ t.get("var.col.type") }}{{ t.get("var.col.status") }}
{{ r.name }}{{ r.mutation_type }}{{ r.naming_status }}
{{ t.get("var.none") }}
+{% if list.total_pages > 1 %} + +{% else %} +{{ list.total }} {{ t.get("pagination.total") }} +{% endif %} diff --git a/rust/crates/du-web/templates/curator/variants/page.html b/rust/crates/du-web/templates/curator/variants/page.html new file mode 100644 index 0000000..99a9e8c --- /dev/null +++ b/rust/crates/du-web/templates/curator/variants/page.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}{{ t.get("var.title") }} — {{ t.get("app.name") }}{% endblock %} +{% block content %} +
+

{{ t.get("var.title") }}

+ +
+
+
+ +
+ {% include "curator/variants/list.html" %} +
+
+
+

{{ t.get("var.select") }}

+
+
+{% endblock %} diff --git a/rust/locales/en.txt b/rust/locales/en.txt index 0d699d7..a06c687 100644 --- a/rust/locales/en.txt +++ b/rust/locales/en.txt @@ -92,6 +92,8 @@ curator.title=Curator Dashboard curator.welcome=Signed in as curator.roles=Roles curator.tool.haplogroups=Haplogroups +curator.tool.variants=Variants +curator.tool.regions=Genome regions hg.title=Haplogroups hg.new=New haplogroup @@ -114,3 +116,42 @@ hg.edit=Edit hg.delete=Delete hg.delete.confirm=Delete this haplogroup? hg.deleteBlocked=Cannot delete: it still has tree relationships. + +var.title=Variants +var.new=New variant +var.search=Search by name or alias… +var.col.name=Name +var.col.type=Type +var.col.status=Status +var.none=No variants match. +var.select=Select a variant, or create one. +var.field.name=Canonical name +var.field.type=Mutation type +var.field.status=Naming status +var.field.commonNames=Common names (comma-separated) +var.field.rsIds=rs IDs (comma-separated) +var.field.builds=Coordinate builds +var.save=Save +var.cancel=Cancel +var.edit=Edit +var.delete=Delete +var.delete.confirm=Delete this variant? +var.deleteBlocked=Cannot delete: it defines a haplogroup. + +region.title=Genome Regions +region.new=New region +region.search=Search by name or type… +region.col.type=Type +region.col.name=Name +region.col.builds=Builds +region.none=No regions match. +region.select=Select a region, or create one. +region.field.type=Region type +region.field.name=Name +region.field.coordinates=Coordinates (JSON) +region.field.properties=Properties (JSON) +region.save=Save +region.cancel=Cancel +region.edit=Edit +region.delete=Delete +region.delete.confirm=Delete this region? diff --git a/rust/locales/es.txt b/rust/locales/es.txt index d92d9b9..0e8a56f 100644 --- a/rust/locales/es.txt +++ b/rust/locales/es.txt @@ -92,6 +92,8 @@ curator.title=Panel del curador curator.welcome=Sesión iniciada como curator.roles=Roles curator.tool.haplogroups=Haplogrupos +curator.tool.variants=Variantes +curator.tool.regions=Regiones genómicas hg.title=Haplogrupos hg.new=Nuevo haplogrupo @@ -114,3 +116,42 @@ hg.edit=Editar hg.delete=Eliminar hg.delete.confirm=¿Eliminar este haplogrupo? hg.deleteBlocked=No se puede eliminar: aún tiene relaciones en el árbol. + +var.title=Variantes +var.new=Nueva variante +var.search=Buscar por nombre o alias… +var.col.name=Nombre +var.col.type=Tipo +var.col.status=Estado +var.none=No hay variantes coincidentes. +var.select=Seleccione una variante o cree una. +var.field.name=Nombre canónico +var.field.type=Tipo de mutación +var.field.status=Estado de nomenclatura +var.field.commonNames=Nombres comunes (separados por comas) +var.field.rsIds=Identificadores rs (separados por comas) +var.field.builds=Ensamblajes de coordenadas +var.save=Guardar +var.cancel=Cancelar +var.edit=Editar +var.delete=Eliminar +var.delete.confirm=¿Eliminar esta variante? +var.deleteBlocked=No se puede eliminar: define un haplogrupo. + +region.title=Regiones genómicas +region.new=Nueva región +region.search=Buscar por nombre o tipo… +region.col.type=Tipo +region.col.name=Nombre +region.col.builds=Ensamblajes +region.none=No hay regiones coincidentes. +region.select=Seleccione una región o cree una. +region.field.type=Tipo de región +region.field.name=Nombre +region.field.coordinates=Coordenadas (JSON) +region.field.properties=Propiedades (JSON) +region.save=Guardar +region.cancel=Cancelar +region.edit=Editar +region.delete=Eliminar +region.delete.confirm=¿Eliminar esta región? diff --git a/rust/locales/fr.txt b/rust/locales/fr.txt index bce5705..f18b9fc 100644 --- a/rust/locales/fr.txt +++ b/rust/locales/fr.txt @@ -92,6 +92,8 @@ curator.title=Tableau de bord du curateur curator.welcome=Connecté en tant que curator.roles=Rôles curator.tool.haplogroups=Haplogroupes +curator.tool.variants=Variants +curator.tool.regions=Régions génomiques hg.title=Haplogroupes hg.new=Nouvel haplogroupe @@ -114,3 +116,42 @@ hg.edit=Modifier hg.delete=Supprimer hg.delete.confirm=Supprimer cet haplogroupe ? hg.deleteBlocked=Suppression impossible : des relations d’arbre existent encore. + +var.title=Variants +var.new=Nouveau variant +var.search=Rechercher par nom ou alias… +var.col.name=Nom +var.col.type=Type +var.col.status=Statut +var.none=Aucun variant correspondant. +var.select=Sélectionnez un variant ou créez-en un. +var.field.name=Nom canonique +var.field.type=Type de mutation +var.field.status=Statut de nomenclature +var.field.commonNames=Noms communs (séparés par des virgules) +var.field.rsIds=Identifiants rs (séparés par des virgules) +var.field.builds=Assemblages de coordonnées +var.save=Enregistrer +var.cancel=Annuler +var.edit=Modifier +var.delete=Supprimer +var.delete.confirm=Supprimer ce variant ? +var.deleteBlocked=Suppression impossible : il définit un haplogroupe. + +region.title=Régions génomiques +region.new=Nouvelle région +region.search=Rechercher par nom ou type… +region.col.type=Type +region.col.name=Nom +region.col.builds=Assemblages +region.none=Aucune région correspondante. +region.select=Sélectionnez une région ou créez-en une. +region.field.type=Type de région +region.field.name=Nom +region.field.coordinates=Coordonnées (JSON) +region.field.properties=Propriétés (JSON) +region.save=Enregistrer +region.cancel=Annuler +region.edit=Modifier +region.delete=Supprimer +region.delete.confirm=Supprimer cette région ? From ef2d33711aef6f84ef3d30f306c2f7d361f451e6 Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 10:16:35 -0500 Subject: [PATCH 010/191] feat(rust): du-migrate ETL (legacy -> new schema) + mock-legacy verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-time ETL binary. Production source is a self-managed Postgres on EC2 — --legacy takes that DSN (typically sslmode=require via opened SG / SSH tunnel), --target the new DB. rustls TLS supports remote/SSL connections. Design: preserve legacy primary keys (OVERRIDING SYSTEM VALUE) and sample_guid UUIDs so all foreign keys carry over 1:1 with no id-remapping. Transformers run in FK order, upsert idempotently (re-runnable/resumable), then identity sequences are advanced and a reconciliation pass compares legacy vs new counts. Transformers: specimen_donor; unified biosample (legacy biosample + citizen_biosample + pgp_biosample -> core.biosample, deriving source, folding at_uri/at_cid into the atproto JSONB and PGP fields into source_attrs); variant (enum normalization, JSONB passthrough); haplogroup (+relationship +variant, Y/MT -> Y_DNA/MT_DNA); genomic_study; publication (+links, resolving legacy integer biosample ids to sample_guid across both link tables). CLI: --legacy/--target/--verify. Reconcile prints a per-aggregate count table. Verified without prod access: scripts/mock-legacy.sql seeds a legacy-shaped subset; the ETL into a fresh target reconciles all 9 aggregates, spot-checks confirm unification/JSONB/enum/geocoord/FK preservation, and a re-run is idempotent. NOTE: transformer SELECTs encode the reconstructed legacy layout — validate against the live EC2 schema before the production run. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/Cargo.lock | 122 ++++++ rust/Cargo.toml | 3 + rust/crates/du-migrate/Cargo.toml | 2 + rust/crates/du-migrate/src/main.rs | 65 +++- rust/crates/du-migrate/src/reconcile.rs | 53 +++ rust/crates/du-migrate/src/transform.rs | 481 ++++++++++++++++++++++++ rust/scripts/mock-legacy.sql | 164 ++++++++ 7 files changed, 884 insertions(+), 6 deletions(-) create mode 100644 rust/crates/du-migrate/src/reconcile.rs create mode 100644 rust/crates/du-migrate/src/transform.rs create mode 100644 rust/scripts/mock-legacy.sql diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5fd3134..9db42b6 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -43,6 +43,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -415,6 +465,52 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compression-codecs" version = "0.4.38" @@ -647,6 +743,7 @@ name = "du-migrate" version = "0.1.0" dependencies = [ "anyhow", + "clap", "du-db", "du-domain", "serde", @@ -655,6 +752,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -1215,6 +1313,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1454,6 +1558,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking" version = "2.2.1" @@ -2253,6 +2363,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2674,6 +2790,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 3d25f35..0d88dc9 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -59,6 +59,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus # URL/query encoding (language-switcher `next` param) percent-encoding = "2" +# CLI +clap = { version = "4", features = ["derive"] } + # Errors / logging / config thiserror = "2" anyhow = "1" diff --git a/rust/crates/du-migrate/Cargo.toml b/rust/crates/du-migrate/Cargo.toml index 394f318..dbec8a1 100644 --- a/rust/crates/du-migrate/Cargo.toml +++ b/rust/crates/du-migrate/Cargo.toml @@ -21,3 +21,5 @@ serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } anyhow = { workspace = true } +clap = { workspace = true } +uuid = { workspace = true } diff --git a/rust/crates/du-migrate/src/main.rs b/rust/crates/du-migrate/src/main.rs index c37a475..8ae58bb 100644 --- a/rust/crates/du-migrate/src/main.rs +++ b/rust/crates/du-migrate/src/main.rs @@ -1,17 +1,70 @@ -//! Legacy -> new schema ETL binary (plan §8). Scaffold only. +//! One-time ETL: legacy DecodingUs Postgres -> redesigned schema (plan §8). //! -//! Usage (planned): -//! decodingus-migrate --legacy --target run the ETL -//! decodingus-migrate --legacy --target --verify reconcile counts +//! The production source is a self-managed Postgres on EC2; point `--legacy` at +//! it (typically `...?sslmode=require`, via an opened security group or an SSH +//! tunnel). `--target` is the new schema's database. +//! +//! Strategy: preserve legacy primary keys (via `OVERRIDING SYSTEM VALUE`) and +//! existing `sample_guid` UUIDs, so foreign keys carry over 1:1 with no id +//! remapping. Transformers run in dependency order, upsert idempotently +//! (re-runnable / resumable), then identity sequences are advanced and a +//! reconciliation pass compares counts. +//! +//! decodingus-migrate --legacy --target run + reconcile +//! decodingus-migrate --legacy --target --verify reconcile only + +use clap::Parser; + +mod reconcile; +mod transform; + +#[derive(Parser, Debug)] +#[command(name = "decodingus-migrate", about = "Legacy -> redesigned schema ETL")] +struct Args { + /// Legacy (source) DSN, e.g. postgres://user:pass@ec2-host:5432/decodingus?sslmode=require + #[arg(long)] + legacy: String, + /// Target (new schema) DSN. + #[arg(long)] + target: String, + /// Reconcile counts only; perform no writes. + #[arg(long)] + verify: bool, +} #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info".into()), + .unwrap_or_else(|_| "info,du_migrate=debug".into()), ) .init(); - tracing::info!("decodingus-migrate scaffold — ETL transformers not yet implemented"); + + let args = Args::parse(); + let legacy = du_db::connect(&args.legacy, 4).await?; + let target = du_db::connect(&args.target, 4).await?; + + if args.verify { + reconcile::run(&legacy, &target).await?; + return Ok(()); + } + + tracing::info!("ETL starting"); + // Dependency order: donors before biosamples; variants/haplogroups before + // their join tables; publications/studies before their links. + transform::specimen_donor(&legacy, &target).await?; + transform::biosample(&legacy, &target).await?; + transform::variant(&legacy, &target).await?; + transform::haplogroup(&legacy, &target).await?; + transform::haplogroup_relationship(&legacy, &target).await?; + transform::haplogroup_variant(&legacy, &target).await?; + transform::genomic_study(&legacy, &target).await?; + transform::publication(&legacy, &target).await?; + transform::publication_biosample(&legacy, &target).await?; + + transform::fix_sequences(&target).await?; + tracing::info!("ETL complete; reconciling"); + reconcile::run(&legacy, &target).await?; Ok(()) } diff --git a/rust/crates/du-migrate/src/reconcile.rs b/rust/crates/du-migrate/src/reconcile.rs new file mode 100644 index 0000000..77db1a1 --- /dev/null +++ b/rust/crates/du-migrate/src/reconcile.rs @@ -0,0 +1,53 @@ +//! Reconciliation: compare legacy vs new row counts per aggregate. Unified +//! aggregates sum the contributing legacy tables. Mismatches are flagged (a +//! lower target count for links can be legitimate de-duplication). + +use sqlx::PgPool; + +struct Check { + label: &'static str, + legacy_sql: &'static str, + target_sql: &'static str, +} + +const CHECKS: &[Check] = &[ + Check { label: "specimen_donor", legacy_sql: "SELECT count(*) FROM specimen_donor", target_sql: "SELECT count(*) FROM core.specimen_donor" }, + Check { + label: "biosample", + legacy_sql: "SELECT (SELECT count(*) FROM biosample) + (SELECT count(*) FROM citizen_biosample) + (SELECT count(*) FROM pgp_biosample)", + target_sql: "SELECT count(*) FROM core.biosample", + }, + Check { label: "variant", legacy_sql: "SELECT count(*) FROM variant_v2", target_sql: "SELECT count(*) FROM core.variant" }, + Check { label: "haplogroup", legacy_sql: "SELECT count(*) FROM tree.haplogroup", target_sql: "SELECT count(*) FROM tree.haplogroup" }, + Check { label: "haplogroup_relationship", legacy_sql: "SELECT count(*) FROM tree.haplogroup_relationship", target_sql: "SELECT count(*) FROM tree.haplogroup_relationship" }, + Check { label: "haplogroup_variant", legacy_sql: "SELECT count(*) FROM tree.haplogroup_variant", target_sql: "SELECT count(*) FROM tree.haplogroup_variant" }, + Check { label: "genomic_study", legacy_sql: "SELECT count(*) FROM genomic_studies", target_sql: "SELECT count(*) FROM pubs.genomic_study" }, + Check { label: "publication", legacy_sql: "SELECT count(*) FROM publication", target_sql: "SELECT count(*) FROM pubs.publication" }, + Check { + label: "publication_biosample", + legacy_sql: "SELECT (SELECT count(*) FROM publication_biosample) + (SELECT count(*) FROM publication_citizen_biosample)", + target_sql: "SELECT count(*) FROM pubs.publication_biosample", + }, +]; + +pub async fn run(legacy: &PgPool, target: &PgPool) -> anyhow::Result<()> { + let mut mismatches = 0; + println!("{:<26} {:>10} {:>10} status", "aggregate", "legacy", "target"); + for c in CHECKS { + let l: i64 = sqlx::query_scalar(c.legacy_sql).fetch_one(legacy).await?; + let t: i64 = sqlx::query_scalar(c.target_sql).fetch_one(target).await?; + let status = if l == t { + "ok" + } else { + mismatches += 1; + "MISMATCH" + }; + println!("{:<26} {:>10} {:>10} {}", c.label, l, t, status); + } + if mismatches > 0 { + tracing::warn!(mismatches, "reconciliation found count mismatches (review before cutover)"); + } else { + tracing::info!("reconciliation: all aggregate counts match"); + } + Ok(()) +} diff --git a/rust/crates/du-migrate/src/transform.rs b/rust/crates/du-migrate/src/transform.rs new file mode 100644 index 0000000..b39fa8a --- /dev/null +++ b/rust/crates/du-migrate/src/transform.rs @@ -0,0 +1,481 @@ +//! Legacy -> new schema transformers. Each reads from the legacy DB and upserts +//! into the new DB, preserving primary keys (`OVERRIDING SYSTEM VALUE`) and +//! `sample_guid` UUIDs so foreign keys carry over unchanged. All upserts are +//! idempotent (`ON CONFLICT ... DO UPDATE`/`DO NOTHING`) so a re-run is safe. +//! +//! NOTE: the SELECT statements encode the *legacy* column layout (reconstructed +//! from the Play app's evolutions). Validate them against the live EC2 schema +//! before the production run; adjust column names here if they differ. + +use serde_json::{json, Value}; +use sqlx::types::chrono::{DateTime, NaiveDate, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +type Ts = Option>; + +fn dna(s: &str) -> &'static str { + match s.trim() { + "MT" | "MT_DNA" | "mtDNA" => "MT_DNA", + _ => "Y_DNA", + } +} + +/// Legacy `biosample_type` / donor_type -> new `biosample_source`. +fn source_from_donor(s: &str) -> &'static str { + match s.trim() { + "External" | "EXTERNAL" => "EXTERNAL", + "Ancient" | "ANCIENT" => "ANCIENT", + "PGP" => "PGP", + "Citizen" | "CITIZEN" => "CITIZEN", + _ => "STANDARD", + } +} + +fn upper_opt(s: Option) -> Option { + s.map(|v| v.trim().to_uppercase()) +} + +// ── specimen donors ────────────────────────────────────────────────────────── +pub async fn specimen_donor(legacy: &PgPool, target: &PgPool) -> anyhow::Result<()> { + let rows = sqlx::query_as::<_, (i64, Option, Option, Option, Option, Option)>( + "SELECT id::bigint, donor_identifier, origin_biobank, sex::text, donor_type::text, ST_AsEWKT(geocoord) \ + FROM specimen_donor", + ) + .fetch_all(legacy) + .await?; + let n = rows.len(); + let mut tx = target.begin().await?; + for (id, ident, biobank, sex, donor_type, ewkt) in rows { + sqlx::query( + "INSERT INTO core.specimen_donor (id, donor_identifier, origin_biobank, sex, donor_type, geocoord) \ + OVERRIDING SYSTEM VALUE \ + VALUES ($1,$2,$3,$4::core.biological_sex,$5::core.biosample_source, ST_GeomFromEWKT($6)) \ + ON CONFLICT (id) DO UPDATE SET donor_identifier=EXCLUDED.donor_identifier, \ + origin_biobank=EXCLUDED.origin_biobank, sex=EXCLUDED.sex, donor_type=EXCLUDED.donor_type, \ + geocoord=EXCLUDED.geocoord", + ) + .bind(id) + .bind(ident) + .bind(biobank) + .bind(upper_opt(sex)) + .bind(source_from_donor(donor_type.as_deref().unwrap_or("Standard"))) + .bind(ewkt) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + tracing::info!(table = "specimen_donor", rows = n, "migrated"); + Ok(()) +} + +// ── unified biosample (3 legacy tables -> 1) ───────────────────────────────── +pub async fn biosample(legacy: &PgPool, target: &PgPool) -> anyhow::Result<()> { + const UPSERT: &str = "INSERT INTO core.biosample \ + (sample_guid, donor_id, source, accession, alias, description, center_name, locked, deleted, \ + source_attrs, original_haplogroups, atproto) \ + VALUES ($1,$2,$3::core.biosample_source,$4,$5,$6,$7,$8,$9,$10,$11,$12) \ + ON CONFLICT (sample_guid) DO UPDATE SET donor_id=EXCLUDED.donor_id, source=EXCLUDED.source, \ + accession=EXCLUDED.accession, alias=EXCLUDED.alias, description=EXCLUDED.description, \ + center_name=EXCLUDED.center_name, locked=EXCLUDED.locked, deleted=EXCLUDED.deleted, \ + source_attrs=EXCLUDED.source_attrs, original_haplogroups=EXCLUDED.original_haplogroups, \ + atproto=EXCLUDED.atproto"; + + let mut tx = target.begin().await?; + let mut total = 0usize; + + // Standard/external/ancient samples — source derived from the donor type. + let std_rows = sqlx::query_as::<_, (Uuid, Option, Option, Option, Option, Option, bool, Option, Value)>( + "SELECT b.sample_guid, b.specimen_donor_id::bigint, b.sample_accession, b.alias, b.description, \ + b.center_name, COALESCE(b.locked,false), d.donor_type::text, \ + COALESCE(b.original_haplogroups,'[]'::jsonb) \ + FROM biosample b LEFT JOIN specimen_donor d ON d.id = b.specimen_donor_id", + ) + .fetch_all(legacy) + .await?; + for (guid, donor, acc, alias, desc, center, locked, donor_type, orig) in std_rows { + sqlx::query(UPSERT) + .bind(guid) + .bind(donor) + .bind(source_from_donor(donor_type.as_deref().unwrap_or("Standard"))) + .bind(acc) + .bind(alias) + .bind(desc) + .bind(center) + .bind(locked) + .bind(false) + .bind(json!({})) + .bind(orig) + .bind(None::) + .execute(&mut *tx) + .await?; + total += 1; + } + + // Citizen samples — federated; at_uri/at_cid fold into the `atproto` doc. + let cit_rows = sqlx::query_as::<_, (Uuid, Option, bool, Value, Option, Option)>( + "SELECT sample_guid, accession, COALESCE(deleted,false), \ + COALESCE(original_haplogroups,'[]'::jsonb), at_uri, at_cid \ + FROM citizen_biosample", + ) + .fetch_all(legacy) + .await?; + for (guid, acc, deleted, orig, at_uri, at_cid) in cit_rows { + let atproto = at_uri.as_ref().map(|uri| json!({ "uri": uri, "cid": at_cid })); + sqlx::query(UPSERT) + .bind(guid) + .bind(None::) + .bind("CITIZEN") + .bind(acc) + .bind(None::) + .bind(None::) + .bind(None::) + .bind(false) + .bind(deleted) + .bind(json!({})) + .bind(orig) + .bind(atproto) + .execute(&mut *tx) + .await?; + total += 1; + } + + // PGP samples — PGP-specific fields fold into source_attrs. + let pgp_rows = sqlx::query_as::<_, (Uuid, Option, Option)>( + "SELECT sample_guid, ena_biosample_accession, pgp_participant_id FROM pgp_biosample", + ) + .fetch_all(legacy) + .await?; + for (guid, ena, pgp_id) in pgp_rows { + let attrs = json!({ "pgp_participant_id": pgp_id, "ena_biosample_accession": ena }); + sqlx::query(UPSERT) + .bind(guid) + .bind(None::) + .bind("PGP") + .bind(ena) + .bind(None::) + .bind(None::) + .bind(None::) + .bind(false) + .bind(false) + .bind(attrs) + .bind(json!([])) + .bind(None::) + .execute(&mut *tx) + .await?; + total += 1; + } + + tx.commit().await?; + tracing::info!(table = "biosample", rows = total, "migrated (standard+citizen+pgp -> core.biosample)"); + Ok(()) +} + +// ── variants ───────────────────────────────────────────────────────────────── +pub async fn variant(legacy: &PgPool, target: &PgPool) -> anyhow::Result<()> { + let rows = sqlx::query_as::<_, (i64, String, String, String, Value, Value, Value)>( + "SELECT variant_id::bigint, canonical_name, mutation_type::text, naming_status::text, \ + COALESCE(aliases,'{}'::jsonb), COALESCE(coordinates,'{}'::jsonb), COALESCE(annotations,'{}'::jsonb) \ + FROM variant_v2", + ) + .fetch_all(legacy) + .await?; + let n = rows.len(); + let mut tx = target.begin().await?; + for (id, name, mtype, nstatus, aliases, coords, annots) in rows { + sqlx::query( + "INSERT INTO core.variant (id, canonical_name, mutation_type, naming_status, aliases, coordinates, annotations) \ + OVERRIDING SYSTEM VALUE \ + VALUES ($1,$2,$3::core.mutation_type,$4::core.naming_status,$5,$6,$7) \ + ON CONFLICT (id) DO UPDATE SET canonical_name=EXCLUDED.canonical_name, mutation_type=EXCLUDED.mutation_type, \ + naming_status=EXCLUDED.naming_status, aliases=EXCLUDED.aliases, coordinates=EXCLUDED.coordinates, \ + annotations=EXCLUDED.annotations", + ) + .bind(id) + .bind(name) + .bind(mtype.trim().to_uppercase()) + .bind(nstatus.trim().to_uppercase()) + .bind(aliases) + .bind(coords) + .bind(annots) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + tracing::info!(table = "variant", rows = n, "migrated"); + Ok(()) +} + +// ── haplogroups + edges + variant associations ────────────────────────────── +pub async fn haplogroup(legacy: &PgPool, target: &PgPool) -> anyhow::Result<()> { + let rows = sqlx::query_as::<_, (i64, String, String, Option, Option, Option, Option, Option, Value, Ts, Ts)>( + "SELECT haplogroup_id::bigint, name, haplogroup_type::text, lineage, source, confidence_level, \ + formed_ybp, tmrca_ybp, COALESCE(provenance,'{}'::jsonb), valid_from, valid_until \ + FROM tree.haplogroup", + ) + .fetch_all(legacy) + .await?; + let n = rows.len(); + let mut tx = target.begin().await?; + for (id, name, htype, lineage, source, conf, formed, tmrca, prov, vfrom, vuntil) in rows { + sqlx::query( + "INSERT INTO tree.haplogroup (id, name, haplogroup_type, lineage, source, confidence_level, \ + formed_ybp, tmrca_ybp, provenance, valid_from, valid_until) OVERRIDING SYSTEM VALUE \ + VALUES ($1,$2,$3::core.dna_type,$4,$5,$6,$7,$8,$9,COALESCE($10, now()),$11) \ + ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, haplogroup_type=EXCLUDED.haplogroup_type, \ + lineage=EXCLUDED.lineage, source=EXCLUDED.source, confidence_level=EXCLUDED.confidence_level, \ + formed_ybp=EXCLUDED.formed_ybp, tmrca_ybp=EXCLUDED.tmrca_ybp, provenance=EXCLUDED.provenance, \ + valid_from=EXCLUDED.valid_from, valid_until=EXCLUDED.valid_until", + ) + .bind(id) + .bind(name) + .bind(dna(&htype)) + .bind(lineage) + .bind(source) + .bind(conf) + .bind(formed) + .bind(tmrca) + .bind(prov) + .bind(vfrom) + .bind(vuntil) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + tracing::info!(table = "haplogroup", rows = n, "migrated"); + Ok(()) +} + +pub async fn haplogroup_relationship(legacy: &PgPool, target: &PgPool) -> anyhow::Result<()> { + let rows = sqlx::query_as::<_, (i64, i64, Option, Option, Option, Ts, Ts)>( + "SELECT id::bigint, child_haplogroup_id::bigint, parent_haplogroup_id::bigint, \ + revision_id, source, valid_from, valid_until FROM tree.haplogroup_relationship", + ) + .fetch_all(legacy) + .await?; + let n = rows.len(); + let mut tx = target.begin().await?; + for (id, child, parent, rev, source, vfrom, vuntil) in rows { + sqlx::query( + "INSERT INTO tree.haplogroup_relationship (id, child_haplogroup_id, parent_haplogroup_id, \ + revision_id, source, revision, valid_from, valid_until) OVERRIDING SYSTEM VALUE \ + VALUES ($1,$2,$3,COALESCE($4,1),$5,'{}'::jsonb,COALESCE($6, now()),$7) \ + ON CONFLICT (id) DO UPDATE SET child_haplogroup_id=EXCLUDED.child_haplogroup_id, \ + parent_haplogroup_id=EXCLUDED.parent_haplogroup_id, revision_id=EXCLUDED.revision_id, \ + source=EXCLUDED.source, valid_from=EXCLUDED.valid_from, valid_until=EXCLUDED.valid_until", + ) + .bind(id) + .bind(child) + .bind(parent) + .bind(rev) + .bind(source) + .bind(vfrom) + .bind(vuntil) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + tracing::info!(table = "haplogroup_relationship", rows = n, "migrated"); + Ok(()) +} + +pub async fn haplogroup_variant(legacy: &PgPool, target: &PgPool) -> anyhow::Result<()> { + let rows = sqlx::query_as::<_, (i64, i64, i64, Ts, Ts)>( + "SELECT haplogroup_variant_id::bigint, haplogroup_id::bigint, variant_id::bigint, valid_from, valid_until \ + FROM tree.haplogroup_variant", + ) + .fetch_all(legacy) + .await?; + let n = rows.len(); + let mut tx = target.begin().await?; + for (id, hg, var, vfrom, vuntil) in rows { + sqlx::query( + "INSERT INTO tree.haplogroup_variant (id, haplogroup_id, variant_id, revision, valid_from, valid_until) \ + OVERRIDING SYSTEM VALUE VALUES ($1,$2,$3,'{}'::jsonb,COALESCE($4, now()),$5) \ + ON CONFLICT (id) DO UPDATE SET haplogroup_id=EXCLUDED.haplogroup_id, variant_id=EXCLUDED.variant_id, \ + valid_from=EXCLUDED.valid_from, valid_until=EXCLUDED.valid_until", + ) + .bind(id) + .bind(hg) + .bind(var) + .bind(vfrom) + .bind(vuntil) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + tracing::info!(table = "haplogroup_variant", rows = n, "migrated"); + Ok(()) +} + +// ── publications + studies + links ─────────────────────────────────────────── +#[derive(sqlx::FromRow)] +struct StudyRow { + id: i64, + accession: String, + title: Option, + center_name: Option, + study_name: Option, + source: Option, + bio_project_id: Option, + molecule: Option, + topology: Option, + taxonomy_id: Option, + version: Option, + submission_date: Option, + details: Value, +} + +pub async fn genomic_study(legacy: &PgPool, target: &PgPool) -> anyhow::Result<()> { + let rows: Vec = sqlx::query_as( + "SELECT id::bigint, accession, title, center_name, study_name, source::text AS source, \ + bio_project_id, molecule, topology, taxonomy_id, version, submission_date, \ + COALESCE(details,'{}'::jsonb) AS details FROM genomic_studies", + ) + .fetch_all(legacy) + .await?; + let n = rows.len(); + let mut tx = target.begin().await?; + for r in rows { + sqlx::query( + "INSERT INTO pubs.genomic_study (id, accession, title, center_name, study_name, source, \ + bio_project_id, molecule, topology, taxonomy_id, version, submission_date, details) \ + OVERRIDING SYSTEM VALUE \ + VALUES ($1,$2,$3,$4,$5,COALESCE($6,'ENA')::pubs.study_source,$7,$8,$9,$10,$11,$12,$13) \ + ON CONFLICT (id) DO UPDATE SET accession=EXCLUDED.accession, title=EXCLUDED.title, \ + center_name=EXCLUDED.center_name, study_name=EXCLUDED.study_name, source=EXCLUDED.source, \ + bio_project_id=EXCLUDED.bio_project_id, molecule=EXCLUDED.molecule, topology=EXCLUDED.topology, \ + taxonomy_id=EXCLUDED.taxonomy_id, version=EXCLUDED.version, submission_date=EXCLUDED.submission_date, \ + details=EXCLUDED.details", + ) + .bind(r.id) + .bind(r.accession) + .bind(r.title) + .bind(r.center_name) + .bind(r.study_name) + .bind(r.source.map(|s| s.to_uppercase())) + .bind(r.bio_project_id) + .bind(r.molecule) + .bind(r.topology) + .bind(r.taxonomy_id) + .bind(r.version) + .bind(r.submission_date) + .bind(r.details) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + tracing::info!(table = "genomic_study", rows = n, "migrated"); + Ok(()) +} + +#[derive(sqlx::FromRow)] +struct PubRow { + id: i64, + pubmed_id: Option, + doi: Option, + open_alex_id: Option, + title: String, + journal: Option, + publication_date: Option, + url: Option, + authors: Option, + abstract_summary: Option, + cited_by_count: Option, + open_access_status: Option, +} + +pub async fn publication(legacy: &PgPool, target: &PgPool) -> anyhow::Result<()> { + let rows: Vec = sqlx::query_as( + "SELECT id::bigint, pubmed_id, doi, open_alex_id, title, journal, publication_date, url, authors, \ + abstract_summary, cited_by_count, open_access_status FROM publication", + ) + .fetch_all(legacy) + .await?; + let n = rows.len(); + let mut tx = target.begin().await?; + for r in rows { + sqlx::query( + "INSERT INTO pubs.publication (id, pubmed_id, doi, open_alex_id, title, journal, publication_date, \ + url, authors, abstract_summary, cited_by_count, open_access_status) OVERRIDING SYSTEM VALUE \ + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) \ + ON CONFLICT (id) DO UPDATE SET pubmed_id=EXCLUDED.pubmed_id, doi=EXCLUDED.doi, \ + open_alex_id=EXCLUDED.open_alex_id, title=EXCLUDED.title, journal=EXCLUDED.journal, \ + publication_date=EXCLUDED.publication_date, url=EXCLUDED.url, authors=EXCLUDED.authors, \ + abstract_summary=EXCLUDED.abstract_summary, cited_by_count=EXCLUDED.cited_by_count, \ + open_access_status=EXCLUDED.open_access_status", + ) + .bind(r.id) + .bind(r.pubmed_id) + .bind(r.doi) + .bind(r.open_alex_id) + .bind(r.title) + .bind(r.journal) + .bind(r.publication_date) + .bind(r.url) + .bind(r.authors) + .bind(r.abstract_summary) + .bind(r.cited_by_count) + .bind(r.open_access_status) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + tracing::info!(table = "publication", rows = n, "migrated"); + Ok(()) +} + +/// Both legacy link tables (standard + citizen) -> the unified link, resolving +/// the legacy integer biosample id to the preserved `sample_guid`. +pub async fn publication_biosample(legacy: &PgPool, target: &PgPool) -> anyhow::Result<()> { + let std = sqlx::query_as::<_, (i64, Uuid)>( + "SELECT pb.publication_id::bigint, b.sample_guid \ + FROM publication_biosample pb JOIN biosample b ON b.id = pb.biosample_id", + ) + .fetch_all(legacy) + .await?; + let cit = sqlx::query_as::<_, (i64, Uuid)>( + "SELECT pcb.publication_id::bigint, c.sample_guid \ + FROM publication_citizen_biosample pcb JOIN citizen_biosample c ON c.id = pcb.citizen_biosample_id", + ) + .fetch_all(legacy) + .await?; + let n = std.len() + cit.len(); + let mut tx = target.begin().await?; + for (pub_id, guid) in std.into_iter().chain(cit) { + sqlx::query( + "INSERT INTO pubs.publication_biosample (publication_id, sample_guid) VALUES ($1,$2) \ + ON CONFLICT DO NOTHING", + ) + .bind(pub_id) + .bind(guid) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + tracing::info!(table = "publication_biosample", rows = n, "migrated"); + Ok(()) +} + +// ── post-load: advance identity sequences past the copied max id ───────────── +pub async fn fix_sequences(target: &PgPool) -> anyhow::Result<()> { + for (schema, table) in [ + ("core", "specimen_donor"), + ("core", "variant"), + ("tree", "haplogroup"), + ("tree", "haplogroup_relationship"), + ("tree", "haplogroup_variant"), + ("pubs", "genomic_study"), + ("pubs", "publication"), + ] { + let qualified = format!("{schema}.{table}"); + // Set the identity sequence so the next nextval is max(id)+1. + let sql = format!( + "SELECT setval(pg_get_serial_sequence('{qualified}','id'), \ + COALESCE((SELECT max(id) FROM {qualified}), 0) + 1, false)" + ); + sqlx::query(&sql).execute(target).await?; + } + tracing::info!("identity sequences advanced"); + Ok(()) +} diff --git a/rust/scripts/mock-legacy.sql b/rust/scripts/mock-legacy.sql new file mode 100644 index 0000000..634cc3a --- /dev/null +++ b/rust/scripts/mock-legacy.sql @@ -0,0 +1,164 @@ +-- Mock of the legacy DecodingUs schema (subset the ETL reads), for verifying +-- du-migrate without production access. Shapes follow the reconstructed legacy +-- layout; the real EC2 schema is the contract for the production run. +CREATE EXTENSION IF NOT EXISTS postgis; +CREATE SCHEMA IF NOT EXISTS tree; + +CREATE TABLE specimen_donor ( + id SERIAL PRIMARY KEY, + donor_identifier TEXT, + origin_biobank TEXT, + sex TEXT, -- legacy biological_sex (lowercase) + donor_type TEXT, -- legacy biosample_type: Standard/External/Ancient/... + geocoord geometry(Point, 4326) +); + +CREATE TABLE biosample ( + id SERIAL PRIMARY KEY, + sample_guid UUID NOT NULL, + sample_accession TEXT, + alias TEXT, + description TEXT, + center_name TEXT, + locked BOOLEAN DEFAULT false, + specimen_donor_id INTEGER REFERENCES specimen_donor(id), + original_haplogroups JSONB DEFAULT '[]'::jsonb +); + +CREATE TABLE citizen_biosample ( + id SERIAL PRIMARY KEY, + sample_guid UUID NOT NULL, + accession TEXT, + deleted BOOLEAN DEFAULT false, + original_haplogroups JSONB DEFAULT '[]'::jsonb, + at_uri TEXT, + at_cid TEXT +); + +CREATE TABLE pgp_biosample ( + pgp_biosample_id SERIAL PRIMARY KEY, + sample_guid UUID NOT NULL, + ena_biosample_accession TEXT, + pgp_participant_id TEXT +); + +CREATE TABLE variant_v2 ( + variant_id SERIAL PRIMARY KEY, + canonical_name TEXT NOT NULL, + mutation_type TEXT, + naming_status TEXT, + aliases JSONB DEFAULT '{}'::jsonb, + coordinates JSONB DEFAULT '{}'::jsonb, + annotations JSONB DEFAULT '{}'::jsonb +); + +CREATE TABLE tree.haplogroup ( + haplogroup_id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + haplogroup_type TEXT, -- Y / MT + lineage TEXT, + source TEXT, + confidence_level TEXT, + formed_ybp INTEGER, + tmrca_ybp INTEGER, + provenance JSONB DEFAULT '{}'::jsonb, + valid_from TIMESTAMPTZ DEFAULT now(), + valid_until TIMESTAMPTZ +); + +CREATE TABLE tree.haplogroup_relationship ( + id SERIAL PRIMARY KEY, + child_haplogroup_id INTEGER, + parent_haplogroup_id INTEGER, + revision_id INTEGER DEFAULT 1, + source TEXT, + valid_from TIMESTAMPTZ DEFAULT now(), + valid_until TIMESTAMPTZ +); + +CREATE TABLE tree.haplogroup_variant ( + haplogroup_variant_id SERIAL PRIMARY KEY, + haplogroup_id INTEGER, + variant_id INTEGER, + valid_from TIMESTAMPTZ DEFAULT now(), + valid_until TIMESTAMPTZ +); + +CREATE TABLE genomic_studies ( + id SERIAL PRIMARY KEY, + accession TEXT NOT NULL, + title TEXT, + center_name TEXT, + study_name TEXT, + source TEXT, + bio_project_id TEXT, + molecule TEXT, + topology TEXT, + taxonomy_id INTEGER, + version INTEGER, + submission_date DATE, + details JSONB DEFAULT '{}'::jsonb +); + +CREATE TABLE publication ( + id SERIAL PRIMARY KEY, + pubmed_id TEXT, + doi TEXT, + open_alex_id TEXT, + title TEXT NOT NULL, + journal TEXT, + publication_date DATE, + url TEXT, + authors TEXT, + abstract_summary TEXT, + cited_by_count INTEGER, + open_access_status TEXT +); + +CREATE TABLE publication_biosample ( + publication_id INTEGER, + biosample_id INTEGER +); +CREATE TABLE publication_citizen_biosample ( + publication_id INTEGER, + citizen_biosample_id INTEGER +); + +-- ── seed ───────────────────────────────────────────────────────────────────── +INSERT INTO specimen_donor (donor_identifier, origin_biobank, sex, donor_type, geocoord) VALUES + ('D1','Biobank A','male','Standard', ST_SetSRID(ST_MakePoint(-0.12,51.50),4326)), + ('D2','Biobank B','female','Ancient', ST_SetSRID(ST_MakePoint(35.0,47.0),4326)); + +INSERT INTO biosample (sample_guid, sample_accession, alias, description, center_name, locked, specimen_donor_id, original_haplogroups) VALUES + ('11111111-1111-1111-1111-111111111111','SAMN001','std-1','Standard sample','Center X', false, 1, '[{"publication_id":1,"y":"R-M269"}]'::jsonb), + ('22222222-2222-2222-2222-222222222222','SAMN002','anc-1','Ancient sample','Center Y', false, 2, '[]'::jsonb); + +INSERT INTO citizen_biosample (sample_guid, accession, deleted, original_haplogroups, at_uri, at_cid) VALUES + ('33333333-3333-3333-3333-333333333333','CIT001', false, '[]'::jsonb, 'at://did:plc:abc123/app.decodingus.biosample/xyz', 'bafyreigh2akiscaildc'); + +INSERT INTO pgp_biosample (sample_guid, ena_biosample_accession, pgp_participant_id) VALUES + ('44444444-4444-4444-4444-444444444444','ERS999','hu1A2B3C'); + +INSERT INTO variant_v2 (canonical_name, mutation_type, naming_status, aliases, coordinates) VALUES + ('M269','SNP','NAMED','{"common_names":["PF6517"],"rs_ids":["rs9786153"]}'::jsonb,'{"GRCh38":{"contig":"chrY","position":22739367}}'::jsonb), + ('L21','snp','named','{"common_names":["S145"]}'::jsonb,'{"GRCh38":{"contig":"chrY","position":13668077}}'::jsonb); + +INSERT INTO tree.haplogroup (name, haplogroup_type, lineage, formed_ybp) VALUES + ('R','Y','R',28200), + ('R1b','Y','R>R1b',22800); + +INSERT INTO tree.haplogroup_relationship (child_haplogroup_id, parent_haplogroup_id) VALUES + (2, 1); -- R1b child of R + +INSERT INTO tree.haplogroup_variant (haplogroup_id, variant_id) VALUES + (2, 1); -- R1b defined by M269 + +INSERT INTO genomic_studies (accession, title, source, taxonomy_id) VALUES + ('PRJEB12345','Steppe ancient genomes','ENA',9606); + +INSERT INTO publication (pubmed_id, doi, title, journal, publication_date, cited_by_count) VALUES + ('30001','10.1000/euro1','Peopling of Europe','Nature','2021-03-15',142), + ('30002','10.1000/steppe2','Steppe Y diversity','Cell','2019-07-01',88); + +INSERT INTO publication_biosample (publication_id, biosample_id) VALUES (1, 1); -- pub1 -> standard biosample 1 +INSERT INTO publication_citizen_biosample (publication_id, citizen_biosample_id) VALUES (2, 1); -- pub2 -> citizen 1 From 25444e3f432e1232c9b91d6fc95fce06453783f1 Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 10:32:40 -0500 Subject: [PATCH 011/191] docs(rust): add port status README Status-oriented README for the Rust port: rationale, stack, workspace layout, schema redesign summary, what's implemented (public surface + auth/curator + ETL), getting started with Apple `container`, testing, ETL usage (EC2 source), deploy, and a roadmap checklist. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/README.md | 189 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 rust/README.md diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..4343c8f --- /dev/null +++ b/rust/README.md @@ -0,0 +1,189 @@ +# DecodingUs — Rust port + +A work-in-progress rewrite of the DecodingUs platform (originally Play Framework +/ Scala 3) in Rust. It coexists with the Scala app under `rust/` during the +transition and will replace it at a single cutover. + +**Status:** foundation + public read surface + auth/curator + ETL are working and +verified against a live PostGIS database. Workspace builds clean; **11/11 tests +pass**. Not yet production-complete — see [Roadmap](#roadmap). + +--- + +## Why the rewrite + +- Drop the JVM's memory/startup overhead for a single static binary. +- Replace a sprawling, accreted schema (~84 tables across 6 schemas + a second + "metadata" DB) with a de-sprawled design that leans on Postgres **JSONB** for + document-shaped data. +- Run fully **Docker-less for local dev/test** on Apple Silicon via Apple's + `container` CLI, while remaining Docker-deployable for production. + +## Stack + +| Concern | Choice | +|---|---| +| Web | **Axum** 0.7 (+ tower / tower-http / tower-cookies) | +| Templates | **Askama** (compile-time typed, Twirl analog) | +| Frontend | **HTMX** 2 + Bootstrap 5 (vendored), HATEOAS-first | +| Database | **SQLx** 0.8 (Postgres, runtime-checked queries) | +| Genomics I/O | **noodles** (pure Rust; replaces JVM htsjdk) — *planned* | +| Async | **tokio** | +| Auth | Argon2 (bcrypt-verify fallback), signed-cookie sessions | +| i18n | embedded `key=value` catalogs (en/es/fr) | +| Local Postgres | Apple `container` running `imresamu/postgis` (arm64) | + +## Workspace layout + +``` +rust/ + crates/ + du-domain/ pure types + (planned) algorithms, no IO; JSONB payload structs + du-db/ SQLx pool + per-aggregate query modules + du-bio/ genomics file I/O (noodles) — scaffold + du-atproto/ AT Protocol / PDS federation — scaffold + du-external/ OpenAlex / ENA / AWS SES / Secrets — scaffold + du-web/ Axum app: routes, Askama templates, i18n, HTMX, auth + du-jobs/ scheduled workers — scaffold + du-migrate/ one-time legacy -> new-schema ETL + migrations/ redesigned schema (0001–0009) + locales/ en / es / fr message catalogs + scripts/ test-db.sh (Apple container), mock-legacy.sql + Dockerfile, compose.yaml, .env.example +``` + +## Schema redesign (`migrations/0001`–`0009`) + +Ten Postgres schemas: `core`, `tree`, `genomics`, `pubs`, `ident`, `ibd`, `fed`, +`social`, `support`, `billing`. Key de-sprawl moves: + +- **3 biosample tables → 1** `core.biosample` (a `source` enum discriminator + + `source_attrs` JSONB). +- **Deprecated child tables folded into JSONB** on their parents (variant aliases + & coordinates, sequence-file checksums/locations, alignment coverage, original + haplogroups). +- The legacy second **"metadata" database collapses into the `fed` schema**. +- Scattered `at_uri`/`at_cid` columns → one consistent **`atproto` JSONB** column. +- PostGIS (`geometry(Point,4326)`), `citext`, native enums, GIN/GiST/expression + indexes on queried JSONB paths. + +## What's implemented + +**Public surface** (server-rendered, HTMX, i18n en/es/fr): + +| Area | Routes | +|---|---| +| Home | `/` | +| Variant browser | `/variants` (+ list/detail fragments; JSONB alias search) | +| Y/MT tree | `/ytree` `/mtree` (unified page/fragment via `HX-Request`) | +| References + biosample report | `/references` (+ list / per-publication report) | +| Biosample map | `/biosamples/map` (PostGIS `ST_X/ST_Y` → Leaflet GeoJSON) | +| Coverage benchmarks | `/coverage-benchmarks` (coverage-JSONB aggregation) | +| Health | `/health` | + +**Auth & curator** (Argon2 + signed-cookie sessions, RBAC): + +- `/login` `/logout`; `Curator` route guard (`TreeCurator`/`Admin`). +- Two-panel HTMX CRUD for **haplogroups**, **variants**, and **genome regions** + (the region editor edits the coordinates/properties JSONB as validated JSON). + Mutations are server-driven via `HX-Trigger` (the panel returns + the list + reloads), with delete guards for referenced rows. + +**ETL** (`du-migrate`): legacy → new schema, preserving PKs and `sample_guid` so +FKs carry over 1:1; idempotent; reconciliation pass. Verified against a mock +legacy DB (see below). + +## Getting started + +### Prerequisites + +- Rust (stable/nightly) — `cargo`. +- **Apple `container`** for the local database. First run once: + ```sh + container system start # installs the default Linux kernel on first run + ``` + (No Docker required. Any `DATABASE_URL` also works as a fallback.) +- *Optional:* `sqlx-cli` (`cargo install sqlx-cli --no-default-features --features postgres,rustls`) + to auto-apply migrations and, later, enable compile-time `query!` checking. + +### Run the app + +```sh +# Start Postgres (PostGIS) and print the DATABASE_URL to export: +eval "$(./scripts/test-db.sh up)" # Apple container gives it its own IP + +# Run the web server (connects + applies migrations on startup): +cargo run -p du-web --bin decodingus # serves on http://localhost:9000 +``` + +Apple `container` assigns each container its own IP (no `localhost` port +forwarding), so `test-db.sh` discovers it and emits the right `DATABASE_URL` +(e.g. `postgres://postgres:dev@192.168.64.2:5432/decodingus`). Stop it with +`./scripts/test-db.sh down`. + +### Seed a curator (to use the curator tools) + +```sh +HASH=$(cargo run -q -p du-web --bin decodingus -- hash-password 'yourpassword') +# then insert ident.users + ident.user_login_info(provider_id='credentials', +# provider_key='', password_hash=$HASH) + ident.user_roles('TreeCurator'). +``` + +## Testing + +```sh +eval "$(./scripts/test-db.sh up)" +cargo test --workspace +``` + +Integration tests are gated on `DATABASE_URL`: with it set they run against the +live PostGIS (migrations, JSONB round-trips, query modules); without it they skip +and the suite stays green. The i18n test enforces that es/fr cover every English +key. + +## Running the ETL + +The production source is a self-managed Postgres on EC2. + +```sh +decodingus-migrate \ + --legacy "postgres://user:pass@ec2-host:5432/decodingus?sslmode=require" \ + --target "$DATABASE_URL" # runs transformers + reconciliation + +decodingus-migrate --legacy ... --target ... --verify # counts only +``` + +Verify the transformers locally first with the mock legacy DB: + +```sh +# create decodingus_legacy + decodingus_etl, load scripts/mock-legacy.sql into the +# former and the migrations into the latter, then run the ETL between them. +``` + +> ⚠️ The transformer `SELECT`s encode the *reconstructed* legacy column layout. +> Validate them against the live EC2 schema before the production run. + +## Deploy + +Multi-stage `Dockerfile` builds a single binary on a slim runtime (no JRE, no C +deps); `compose.yaml` runs it with `postgis/postgis`. `SQLX_OFFLINE=true` is set +for DB-less builds. + +## Roadmap + +- [x] Workspace + redesigned schema (verified on live PostGIS) +- [x] `du-db` query modules + read-side domain types +- [x] Public read surface (trees, variants, references, map, coverage) +- [x] Asset vendoring, i18n (en/es/fr), `HX-Request` negotiation +- [x] Session auth + RBAC; curator CRUD (haplogroups, variants, regions) +- [x] `du-migrate` ETL core aggregates (verified vs mock legacy DB) +- [ ] ETL: remaining aggregates (genomics, ibd, ident, fed, social, billing) — + validate read SQL against the live EC2 schema +- [ ] Genomics ingestion (`du-bio` / noodles) + scheduled jobs (`du-jobs`) +- [ ] AT Protocol federation (`du-atproto`): DID login, firehose, PDS fleet +- [ ] External clients (`du-external`): OpenAlex, ENA, AWS SES/Secrets +- [ ] Tree-versioning change-sets; haplogroup↔variant association editing +- [ ] Vendor remaining assets; full OpenAPI parity; cutover rehearsal + +> The tree-merge algorithm is a known-buggy area in the legacy app and will be +> re-implemented (not faithfully ported) when that subsystem lands. From 126b6cf9ecbe2c32659a1d707aeadbb0eaf58e42 Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 10:49:36 -0500 Subject: [PATCH 012/191] feat(rust): du-atproto identity/crypto core; federation pivots to permissions/OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Federation direction updated after reviewing current atproto specs: the custom "private firehose" is dropped in favor of the protocol's permissions/OAuth + notify-then-fetch model (private data deliberately bypasses the firehose; consumers fetch records from the PDS over scoped OAuth). Group-private data spec is still maturing upstream. This lands the foundation needed under any model: - did: DID + AT-URI parsing; did:key <-> Ed25519 pubkey (multibase + multicodec). - signature: verify_did_key — Ed25519 verification against a self-certifying did:key (no network needed); tested with sign/verify/tamper/wrong-key. - resolve: DID-document parsing (PDS endpoint, handle, signing did:key) + a Resolver for handle->DID (well-known) and DID->doc (PLC directory / did:web); parsing unit-tested via fixture, HTTP fetch isolated. README roadmap updated for the pivot. Workspace 17/17 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/Cargo.lock | 413 +++++++++++++++++++++++- rust/Cargo.toml | 4 + rust/README.md | 9 +- rust/crates/du-atproto/Cargo.toml | 8 +- rust/crates/du-atproto/src/did.rs | 113 +++++++ rust/crates/du-atproto/src/error.rs | 17 + rust/crates/du-atproto/src/lib.rs | 20 +- rust/crates/du-atproto/src/resolve.rs | 177 ++++++++++ rust/crates/du-atproto/src/signature.rs | 61 ++++ 9 files changed, 802 insertions(+), 20 deletions(-) create mode 100644 rust/crates/du-atproto/src/did.rs create mode 100644 rust/crates/du-atproto/src/error.rs create mode 100644 rust/crates/du-atproto/src/resolve.rs create mode 100644 rust/crates/du-atproto/src/signature.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9db42b6..9d78dc7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -272,6 +272,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + [[package]] name = "base64" version = "0.22.1" @@ -543,6 +559,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + [[package]] name = "cookie" version = "0.18.1" @@ -552,7 +574,7 @@ dependencies = [ "base64", "hmac", "percent-encoding", - "rand", + "rand 0.8.6", "sha2", "subtle", "time", @@ -623,6 +645,59 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.117", +] + [[package]] name = "der" version = "0.7.10" @@ -676,7 +751,11 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" name = "du-atproto" version = "0.1.0" dependencies = [ + "base64", "du-domain", + "ed25519-dalek", + "multibase", + "reqwest", "serde", "serde_json", "thiserror", @@ -778,6 +857,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -825,6 +928,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -973,8 +1082,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -985,7 +1110,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -1142,6 +1267,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -1150,13 +1292,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1313,6 +1463,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1407,6 +1563,23 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1481,6 +1654,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + [[package]] name = "nom" version = "7.1.3" @@ -1511,7 +1696,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1600,7 +1785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1730,6 +1915,61 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1739,6 +1979,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1758,8 +2004,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1769,7 +2025,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1781,6 +2047,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1825,6 +2100,44 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + [[package]] name = "ring" version = "0.17.14" @@ -1881,7 +2194,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -1898,13 +2211,28 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.6", "rkyv", "serde", "serde_json", "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1925,6 +2253,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2089,7 +2418,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2267,7 +2596,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.6", "rsa", "serde", "sha1", @@ -2307,7 +2636,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.6", "serde", "serde_json", "sha2", @@ -2402,6 +2731,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2533,6 +2865,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2642,9 +2984,11 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -2721,6 +3065,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -2826,6 +3176,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2870,6 +3229,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.122" @@ -2936,6 +3305,26 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 0d88dc9..6e7daf6 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -59,6 +59,10 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus # URL/query encoding (language-switcher `next` param) percent-encoding = "2" +# AT Protocol identity/crypto +multibase = "0.9" +base64 = "0.22" + # CLI clap = { version = "4", features = ["derive"] } diff --git a/rust/README.md b/rust/README.md index 4343c8f..3296f32 100644 --- a/rust/README.md +++ b/rust/README.md @@ -41,7 +41,7 @@ rust/ du-domain/ pure types + (planned) algorithms, no IO; JSONB payload structs du-db/ SQLx pool + per-aggregate query modules du-bio/ genomics file I/O (noodles) — scaffold - du-atproto/ AT Protocol / PDS federation — scaffold + du-atproto/ AT Protocol identity/crypto (did:key verify, DID/PDS resolution); OAuth client next du-external/ OpenAlex / ENA / AWS SES / Secrets — scaffold du-web/ Axum app: routes, Askama templates, i18n, HTMX, auth du-jobs/ scheduled workers — scaffold @@ -180,7 +180,12 @@ for DB-less builds. - [ ] ETL: remaining aggregates (genomics, ibd, ident, fed, social, billing) — validate read SQL against the live EC2 schema - [ ] Genomics ingestion (`du-bio` / noodles) + scheduled jobs (`du-jobs`) -- [ ] AT Protocol federation (`du-atproto`): DID login, firehose, PDS fleet +- [x] AT Protocol identity/crypto core (`du-atproto`): DID/AT-URI parse, did:key + Ed25519 verification, DID-doc/PDS resolution +- [ ] AT Protocol OAuth client + permission sets (PAR/DPoP/scopes) → PDS login. + Federation pivoted away from a custom private firehose toward protocol + permissions/OAuth + notify-then-fetch (private data bypasses the firehose); + group-private data spec is still maturing upstream - [ ] External clients (`du-external`): OpenAlex, ENA, AWS SES/Secrets - [ ] Tree-versioning change-sets; haplogroup↔variant association editing - [ ] Vendor remaining assets; full OpenAPI parity; cutover rehearsal diff --git a/rust/crates/du-atproto/Cargo.toml b/rust/crates/du-atproto/Cargo.toml index 16757f3..1d31be7 100644 --- a/rust/crates/du-atproto/Cargo.toml +++ b/rust/crates/du-atproto/Cargo.toml @@ -5,10 +5,14 @@ edition.workspace = true rust-version.workspace = true license.workspace = true -# AT Protocol: DID/handle resolution, Ed25519 signature verification, firehose -# decode, PDS client/fleet. Crypto + HTTP deps added during implementation. +# AT Protocol: DID/handle identity, did:key Ed25519 signature verification, and +# DID-document/PDS resolution. Foundation for the OAuth client + notify/fetch. [dependencies] du-domain = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +ed25519-dalek = { workspace = true } +multibase = { workspace = true } +base64 = { workspace = true } +reqwest = { workspace = true } diff --git a/rust/crates/du-atproto/src/did.rs b/rust/crates/du-atproto/src/did.rs new file mode 100644 index 0000000..bf09b25 --- /dev/null +++ b/rust/crates/du-atproto/src/did.rs @@ -0,0 +1,113 @@ +//! DID and AT-URI parsing, plus `did:key` <-> Ed25519 public key conversion. + +use crate::error::AtprotoError; +use ed25519_dalek::VerifyingKey; + +/// Multicodec prefix for an Ed25519 public key (`0xed`, varint-encoded as 0xed 0x01). +const ED25519_PUB_MULTICODEC: [u8; 2] = [0xed, 0x01]; + +/// A decentralized identifier. Supports the `did:plc:` and `did:web:` methods +/// used in AT Protocol, plus `did:key:` for self-certifying signing identities. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Did(pub String); + +impl Did { + pub fn parse(s: &str) -> Result { + let s = s.trim(); + if !s.starts_with("did:") || s.splitn(3, ':').count() < 3 { + return Err(AtprotoError::Parse(format!("not a DID: {s:?}"))); + } + Ok(Did(s.to_string())) + } + + pub fn method(&self) -> &str { + // did:: + self.0.split(':').nth(1).unwrap_or("") + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Did { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A parsed AT-URI: `at:////` (collection and rkey +/// optional). The authority is a DID or handle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtUri { + pub authority: String, + pub collection: Option, + pub rkey: Option, +} + +impl AtUri { + pub fn parse(s: &str) -> Result { + let rest = s + .strip_prefix("at://") + .ok_or_else(|| AtprotoError::Parse(format!("not an at:// URI: {s:?}")))?; + let mut parts = rest.splitn(3, '/'); + let authority = parts + .next() + .filter(|a| !a.is_empty()) + .ok_or_else(|| AtprotoError::Parse("at-uri missing authority".into()))? + .to_string(); + Ok(AtUri { + authority, + collection: parts.next().map(str::to_string).filter(|s| !s.is_empty()), + rkey: parts.next().map(str::to_string).filter(|s| !s.is_empty()), + }) + } +} + +/// Decode a `did:key:z...` Ed25519 identity into a verifying key. +pub fn ed25519_from_did_key(did_key: &str) -> Result { + let mb = did_key + .strip_prefix("did:key:") + .ok_or_else(|| AtprotoError::Parse("not a did:key".into()))?; + let (_base, data) = multibase::decode(mb).map_err(|e| AtprotoError::Parse(e.to_string()))?; + let key = data + .strip_prefix(&ED25519_PUB_MULTICODEC[..]) + .ok_or_else(|| AtprotoError::Unsupported("did:key is not Ed25519".into()))?; + let arr: [u8; 32] = key + .try_into() + .map_err(|_| AtprotoError::Parse("Ed25519 key not 32 bytes".into()))?; + VerifyingKey::from_bytes(&arr).map_err(|e| AtprotoError::Crypto(e.to_string())) +} + +/// Encode an Ed25519 public key as a `did:key:z...` string (multibase base58btc). +pub fn did_key_from_ed25519(vk: &VerifyingKey) -> String { + let mut bytes = ED25519_PUB_MULTICODEC.to_vec(); + bytes.extend_from_slice(vk.as_bytes()); + format!("did:key:{}", multibase::encode(multibase::Base::Base58Btc, bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_dids_and_methods() { + assert_eq!(Did::parse("did:plc:abc123").unwrap().method(), "plc"); + assert_eq!(Did::parse("did:web:example.com").unwrap().method(), "web"); + assert!(Did::parse("nope").is_err()); + } + + #[test] + fn parses_at_uris() { + let u = AtUri::parse("at://did:plc:abc/app.decodingus.biosample/3k2l").unwrap(); + assert_eq!(u.authority, "did:plc:abc"); + assert_eq!(u.collection.as_deref(), Some("app.decodingus.biosample")); + assert_eq!(u.rkey.as_deref(), Some("3k2l")); + + let bare = AtUri::parse("at://did:plc:abc").unwrap(); + assert_eq!(bare.authority, "did:plc:abc"); + assert!(bare.collection.is_none()); + + assert!(AtUri::parse("https://x").is_err()); + } +} diff --git a/rust/crates/du-atproto/src/error.rs b/rust/crates/du-atproto/src/error.rs new file mode 100644 index 0000000..e29e3b8 --- /dev/null +++ b/rust/crates/du-atproto/src/error.rs @@ -0,0 +1,17 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AtprotoError { + #[error("parse error: {0}")] + Parse(String), + #[error("unsupported: {0}")] + Unsupported(String), + #[error("signature verification failed")] + BadSignature, + #[error("crypto error: {0}")] + Crypto(String), + #[error("resolution failed: {0}")] + Resolve(String), + #[error("http error: {0}")] + Http(#[from] reqwest::Error), +} diff --git a/rust/crates/du-atproto/src/lib.rs b/rust/crates/du-atproto/src/lib.rs index 0c3939b..37ac818 100644 --- a/rust/crates/du-atproto/src/lib.rs +++ b/rust/crates/du-atproto/src/lib.rs @@ -1,5 +1,17 @@ -//! AT Protocol / PDS federation (plan §7). Scaffold only. +//! AT Protocol identity, crypto, and resolution for DecodingUs (plan §7). //! -//! Planned: `did` (PLC directory + handle resolution), `signature` (Ed25519 -//! multibase verification against AT Protocol spec test vectors), `firehose` -//! (Atmosphere lexicon event decode), `pds` (client + fleet heartbeat/submission). +//! Federation direction (June 2026): the custom "private firehose" is replaced +//! by the protocol's permissions/OAuth + notify-then-fetch model. This crate is +//! the foundation needed under either model — DID/handle identity, `did:key` +//! Ed25519 signature verification, and DID-document/PDS resolution. The OAuth +//! client (permission sets, PAR, DPoP) builds on top of this next. + +pub mod did; +pub mod error; +pub mod resolve; +pub mod signature; + +pub use did::{AtUri, Did}; +pub use error::AtprotoError; +pub use resolve::{DidDocument, Resolver}; +pub use signature::verify_did_key; diff --git a/rust/crates/du-atproto/src/resolve.rs b/rust/crates/du-atproto/src/resolve.rs new file mode 100644 index 0000000..3ed0cf5 --- /dev/null +++ b/rust/crates/du-atproto/src/resolve.rs @@ -0,0 +1,177 @@ +//! Handle/DID resolution and DID-document parsing. +//! +//! - handle -> DID via the HTTPS well-known method (`/.well-known/atproto-did`). +//! (The DNS `_atproto` TXT method is a future addition; it needs a DNS dep.) +//! - DID -> DID document via the PLC directory (`did:plc`) or `did:web`. +//! - From the document: the PDS service endpoint and the signing `did:key`. +//! +//! Document parsing is pure and unit-tested; the HTTP fetch is isolated in +//! `Resolver`. + +use crate::did::Did; +use crate::error::AtprotoError; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct DidDocument { + pub id: String, + #[serde(default, rename = "alsoKnownAs")] + pub also_known_as: Vec, + #[serde(default, rename = "verificationMethod")] + pub verification_method: Vec, + #[serde(default)] + pub service: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct VerificationMethod { + pub id: String, + #[serde(rename = "type")] + pub typ: String, + #[serde(rename = "publicKeyMultibase")] + pub public_key_multibase: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Service { + pub id: String, + #[serde(rename = "type")] + pub typ: String, + #[serde(rename = "serviceEndpoint")] + pub service_endpoint: String, +} + +impl DidDocument { + /// The PDS service endpoint (`#atproto_pds` / `AtprotoPersonalDataServer`). + pub fn pds_endpoint(&self) -> Option<&str> { + self.service + .iter() + .find(|s| s.id.ends_with("#atproto_pds") || s.typ == "AtprotoPersonalDataServer") + .map(|s| s.service_endpoint.as_str()) + } + + /// The primary handle (`alsoKnownAs` `at://`), if any. + pub fn handle(&self) -> Option { + self.also_known_as + .iter() + .find_map(|a| a.strip_prefix("at://").map(str::to_string)) + } + + /// The signing key as a `did:key` (the Multikey `publicKeyMultibase` is the + /// did:key suffix). + pub fn signing_did_key(&self) -> Option { + self.verification_method + .iter() + .find_map(|vm| vm.public_key_multibase.as_ref().map(|m| format!("did:key:{m}"))) + } +} + +/// Resolves handles and DIDs over HTTPS. +pub struct Resolver { + client: reqwest::Client, + plc_directory: String, +} + +impl Default for Resolver { + fn default() -> Self { + Self::new() + } +} + +impl Resolver { + pub fn new() -> Self { + Resolver { + client: reqwest::Client::new(), + plc_directory: "https://plc.directory".to_string(), + } + } + + /// handle -> DID via `https:///.well-known/atproto-did`. + pub async fn resolve_handle(&self, handle: &str) -> Result { + let url = format!("https://{handle}/.well-known/atproto-did"); + let body = self.client.get(url).send().await?.error_for_status()?.text().await?; + Did::parse(body.trim()) + } + + /// DID -> DID document (`did:plc` via PLC directory, `did:web` via well-known). + pub async fn resolve_did(&self, did: &Did) -> Result { + let url = match did.method() { + "plc" => format!("{}/{}", self.plc_directory, did.as_str()), + "web" => did_web_doc_url(did.as_str())?, + m => return Err(AtprotoError::Unsupported(format!("did method: {m}"))), + }; + let doc = self + .client + .get(url) + .send() + .await? + .error_for_status()? + .json::() + .await?; + Ok(doc) + } + + /// Convenience: resolve a DID straight to its PDS endpoint. + pub async fn resolve_pds(&self, did: &Did) -> Result { + self.resolve_did(did) + .await? + .pds_endpoint() + .map(str::to_string) + .ok_or_else(|| AtprotoError::Resolve("no PDS endpoint in DID document".into())) + } +} + +/// `did:web:example.com` -> `https://example.com/.well-known/did.json`; +/// `did:web:example.com:u:alice` -> `https://example.com/u/alice/did.json`. +fn did_web_doc_url(did: &str) -> Result { + let rest = did + .strip_prefix("did:web:") + .ok_or_else(|| AtprotoError::Parse("not did:web".into()))?; + let mut parts = rest.split(':'); + let host = parts.next().unwrap_or("").replace("%3A", ":"); + let path: Vec<&str> = parts.collect(); + if path.is_empty() { + Ok(format!("https://{host}/.well-known/did.json")) + } else { + Ok(format!("https://{host}/{}/did.json", path.join("/"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // r##"…"## because the JSON contains the sequence `"#` (in "#atproto_pds"). + const FIXTURE: &str = r##"{ + "id": "did:plc:abc123", + "alsoKnownAs": ["at://alice.example.com"], + "verificationMethod": [{ + "id": "did:plc:abc123#atproto", + "type": "Multikey", + "controller": "did:plc:abc123", + "publicKeyMultibase": "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" + }], + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": "https://pds.example.com" + }] + }"##; + + #[test] + fn parses_did_document_pds_handle_and_key() { + let doc: DidDocument = serde_json::from_str(FIXTURE).unwrap(); + assert_eq!(doc.pds_endpoint(), Some("https://pds.example.com")); + assert_eq!(doc.handle().as_deref(), Some("alice.example.com")); + assert_eq!( + doc.signing_did_key().as_deref(), + Some("did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF") + ); + } + + #[test] + fn did_web_url_host_and_path() { + assert_eq!(did_web_doc_url("did:web:example.com").unwrap(), "https://example.com/.well-known/did.json"); + assert_eq!(did_web_doc_url("did:web:example.com:u:alice").unwrap(), "https://example.com/u/alice/did.json"); + } +} diff --git a/rust/crates/du-atproto/src/signature.rs b/rust/crates/du-atproto/src/signature.rs new file mode 100644 index 0000000..f2ade13 --- /dev/null +++ b/rust/crates/du-atproto/src/signature.rs @@ -0,0 +1,61 @@ +//! Ed25519 signature verification against a `did:key` identity. Self-certifying: +//! the public key is encoded in the DID, so no network resolution is needed to +//! verify a payload signed by that key (used for signed PDS/edge-node requests +//! and IBD attestations). + +use crate::did::ed25519_from_did_key; +use crate::error::AtprotoError; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use ed25519_dalek::Signature; + +/// Verify that `signature_b64` (standard base64 of a 64-byte Ed25519 signature) +/// over `message` was produced by the key in `did_key` (`did:key:z...`). +pub fn verify_did_key(did_key: &str, message: &[u8], signature_b64: &str) -> Result<(), AtprotoError> { + let vk = ed25519_from_did_key(did_key)?; + let sig_bytes = STANDARD + .decode(signature_b64.trim()) + .map_err(|e| AtprotoError::Parse(format!("signature base64: {e}")))?; + let sig = Signature::from_slice(&sig_bytes) + .map_err(|e| AtprotoError::Parse(format!("signature bytes: {e}")))?; + vk.verify_strict(message, &sig).map_err(|_| AtprotoError::BadSignature) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::did::did_key_from_ed25519; + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + use ed25519_dalek::{Signer, SigningKey}; + + fn keypair() -> SigningKey { + // Deterministic seed (tests must not use RNG that breaks reproducibility). + SigningKey::from_bytes(&[7u8; 32]) + } + + #[test] + fn verifies_a_valid_signature() { + let sk = keypair(); + let did = did_key_from_ed25519(&sk.verifying_key()); + assert!(did.starts_with("did:key:z")); + let msg = b"de-identified call signature payload"; + let sig = STANDARD.encode(sk.sign(msg).to_bytes()); + assert!(verify_did_key(&did, msg, &sig).is_ok()); + } + + #[test] + fn rejects_tampered_message_and_wrong_key() { + let sk = keypair(); + let did = did_key_from_ed25519(&sk.verifying_key()); + let sig = STANDARD.encode(sk.sign(b"original").to_bytes()); + assert!(matches!( + verify_did_key(&did, b"tampered", &sig), + Err(AtprotoError::BadSignature) + )); + + let other = SigningKey::from_bytes(&[9u8; 32]); + let other_did = did_key_from_ed25519(&other.verifying_key()); + assert!(verify_did_key(&other_did, b"original", &sig).is_err()); + } +} From 43c0524df28e38a78eadeb33adf35ada653b2805 Mon Sep 17 00:00:00 2001 From: James Kane Date: Mon, 1 Jun 2026 11:10:40 -0500 Subject: [PATCH 013/191] feat(rust): AT Protocol OAuth client wiring + Edge findings The federation wiring pieces (consumer side of permissions/OAuth). Live handshake needs the Edge team's PDS; everything up to the network exchange is implemented and unit-tested. du-atproto oauth module (unit-tested, offline): - PKCE S256 (verified vs RFC 7636 vector), ES256 JOSE sign + JWK + RFC 7638 thumbprint, DPoP proof JWTs, private_key_jwt client assertion. - ClientMetadata (confidential web: private_key_jwt + ES256 + DPoP) and AuthServerMetadata + protected-resource discovery; PAR/authorize/token builders. du-web wiring: - OauthClient from env (OAUTH_BASE_URL/SCOPE/EC_KEY; disabled when unset). - Serves /oauth/client-metadata.json and /oauth/jwks.json (public key only). - /login/atproto: resolve handle->DID->PDS->authserver, PAR (DPoP, nonce retry), redirect; /oauth/callback: token exchange -> upsert user by DID -> session. - du-db: upsert_user_by_did (find-or-create + atproto login_info). - AppError::Upstream (502) for federation failures. Verified live: metadata + JWKS serve correctly (no private material leaks); /login with a bogus handle fails gracefully (502). Full flow pending Edge PDS. docs/atproto-oauth-findings.md enumerates the integration points to settle with the Edge team (client registration, hosting, scopes/permission sets, key lifecycle, DPoP nonce, identity resolution, notify-fetch). Workspace 22/22 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/Cargo.lock | 124 +++++++++ rust/Cargo.toml | 3 + rust/crates/du-atproto/Cargo.toml | 3 + rust/crates/du-atproto/src/lib.rs | 1 + rust/crates/du-atproto/src/oauth.rs | 381 +++++++++++++++++++++++++++ rust/crates/du-db/src/auth.rs | 32 +++ rust/crates/du-web/Cargo.toml | 2 + rust/crates/du-web/src/error.rs | 12 + rust/crates/du-web/src/main.rs | 6 +- rust/crates/du-web/src/oauth.rs | 289 ++++++++++++++++++++ rust/crates/du-web/src/routes/mod.rs | 1 + rust/crates/du-web/src/state.rs | 4 + rust/docs/atproto-oauth-findings.md | 76 ++++++ 13 files changed, 932 insertions(+), 2 deletions(-) create mode 100644 rust/crates/du-atproto/src/oauth.rs create mode 100644 rust/crates/du-web/src/oauth.rs create mode 100644 rust/docs/atproto-oauth-findings.md diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9d78dc7..a5f4135 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -278,6 +278,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base256emoji" version = "1.0.2" @@ -635,6 +641,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -755,9 +773,12 @@ dependencies = [ "du-domain", "ed25519-dalek", "multibase", + "p256", + "rand_core 0.6.4", "reqwest", "serde", "serde_json", + "sha2", "thiserror", ] @@ -843,9 +864,11 @@ dependencies = [ "askama", "axum", "bcrypt", + "du-atproto", "du-db", "du-domain", "percent-encoding", + "reqwest", "serde", "serde_json", "tokio", @@ -857,6 +880,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -890,6 +927,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -928,6 +985,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1073,6 +1140,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1115,6 +1183,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1749,6 +1828,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -1877,6 +1968,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2138,6 +2238,16 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2292,6 +2402,20 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 6e7daf6..85a7131 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -62,6 +62,9 @@ percent-encoding = "2" # AT Protocol identity/crypto multibase = "0.9" base64 = "0.22" +sha2 = "0.10" +p256 = { version = "0.13", features = ["ecdsa"] } +rand_core = { version = "0.6", features = ["getrandom"] } # CLI clap = { version = "4", features = ["derive"] } diff --git a/rust/crates/du-atproto/Cargo.toml b/rust/crates/du-atproto/Cargo.toml index 1d31be7..15d9859 100644 --- a/rust/crates/du-atproto/Cargo.toml +++ b/rust/crates/du-atproto/Cargo.toml @@ -16,3 +16,6 @@ ed25519-dalek = { workspace = true } multibase = { workspace = true } base64 = { workspace = true } reqwest = { workspace = true } +sha2 = { workspace = true } +p256 = { workspace = true } +rand_core = { workspace = true } diff --git a/rust/crates/du-atproto/src/lib.rs b/rust/crates/du-atproto/src/lib.rs index 37ac818..651d543 100644 --- a/rust/crates/du-atproto/src/lib.rs +++ b/rust/crates/du-atproto/src/lib.rs @@ -8,6 +8,7 @@ pub mod did; pub mod error; +pub mod oauth; pub mod resolve; pub mod signature; diff --git a/rust/crates/du-atproto/src/oauth.rs b/rust/crates/du-atproto/src/oauth.rs new file mode 100644 index 0000000..55f94e3 --- /dev/null +++ b/rust/crates/du-atproto/src/oauth.rs @@ -0,0 +1,381 @@ +//! AT Protocol OAuth client wiring (plan §7; federation pivot to permissions/ +//! OAuth). The spec-defined, testable pieces: PKCE, ES256 JOSE (client assertion +//! + DPoP proofs), client + authorization-server metadata, and request builders. +//! +//! The interactive handshake (PAR -> redirect -> token) is orchestrated by +//! du-web on top of these; it requires a live PDS / authorization server, so the +//! end-to-end flow is exercised with the Edge team (see docs/atproto-oauth-findings.md). + +use crate::error::AtprotoError; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use p256::ecdsa::{signature::Signer, Signature, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +fn b64u(bytes: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(bytes) +} + +// ── EC (P-256 / ES256) client key ──────────────────────────────────────────── + +/// The client's P-256 signing key (used for `private_key_jwt` client assertions +/// and DPoP proofs). Persisted as the base64url-encoded 32-byte scalar. +pub struct EcKey { + signing: SigningKey, +} + +impl EcKey { + pub fn generate() -> Self { + EcKey { signing: SigningKey::random(&mut rand_core::OsRng) } + } + + pub fn from_base64(s: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(s.trim()) + .map_err(|e| AtprotoError::Parse(format!("ec key base64: {e}")))?; + let signing = SigningKey::from_slice(&bytes).map_err(|e| AtprotoError::Crypto(e.to_string()))?; + Ok(EcKey { signing }) + } + + pub fn to_base64(&self) -> String { + b64u(&self.signing.to_bytes()) + } + + pub fn verifying_key(&self) -> VerifyingKey { + *self.signing.verifying_key() + } + + /// Public key as a JWK (`{kty,crv,x,y}`), plus `kid` set to the thumbprint. + pub fn public_jwk(&self) -> serde_json::Value { + let (x, y) = self.xy(); + serde_json::json!({ + "kty": "EC", "crv": "P-256", "x": x, "y": y, + "use": "sig", "alg": "ES256", "kid": self.thumbprint(), + }) + } + + /// Bare public JWK used inside a DPoP header (no kid/use/alg). + pub fn dpop_jwk(&self) -> serde_json::Value { + let (x, y) = self.xy(); + serde_json::json!({ "kty": "EC", "crv": "P-256", "x": x, "y": y }) + } + + /// RFC 7638 JWK thumbprint (base64url SHA-256 of the canonical JWK). + pub fn thumbprint(&self) -> String { + let (x, y) = self.xy(); + // Canonical member ordering: crv, kty, x, y. + let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}"#); + b64u(&Sha256::digest(canonical.as_bytes())) + } + + fn xy(&self) -> (String, String) { + let ep = self.verifying_key().to_encoded_point(false); + (b64u(ep.x().unwrap()), b64u(ep.y().unwrap())) + } + + /// Sign a compact JWS (`b64u(header).b64u(payload).b64u(sig)`) with ES256. + pub fn sign_jws(&self, header: &serde_json::Value, payload: &serde_json::Value) -> String { + let signing_input = format!( + "{}.{}", + b64u(header.to_string().as_bytes()), + b64u(payload.to_string().as_bytes()) + ); + let sig: Signature = self.signing.sign(signing_input.as_bytes()); + format!("{}.{}", signing_input, b64u(&sig.to_bytes())) + } +} + +// ── PKCE ───────────────────────────────────────────────────────────────────── + +pub struct Pkce { + pub verifier: String, + pub challenge: String, +} + +impl Pkce { + pub fn generate() -> Self { + let mut bytes = [0u8; 32]; + rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut bytes); + Self::from_verifier(b64u(&bytes)) + } + + pub fn from_verifier(verifier: String) -> Self { + let challenge = b64u(&Sha256::digest(verifier.as_bytes())); + Pkce { verifier, challenge } + } +} + +/// A random URL-safe token (PKCE-style), for `state` / `jti`. +pub fn random_token() -> String { + let mut bytes = [0u8; 24]; + rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut bytes); + b64u(&bytes) +} + +// ── JWTs: client assertion + DPoP proof ────────────────────────────────────── + +/// `private_key_jwt` client assertion for authenticating at PAR/token endpoints. +pub fn client_assertion(key: &EcKey, client_id: &str, audience: &str, iat: i64) -> String { + let header = serde_json::json!({ "alg": "ES256", "typ": "JWT", "kid": key.thumbprint() }); + let payload = serde_json::json!({ + "iss": client_id, "sub": client_id, "aud": audience, + "jti": random_token(), "iat": iat, "exp": iat + 300, + }); + key.sign_jws(&header, &payload) +} + +/// A DPoP proof JWT binding a request (`htm` method, `htu` URL) to the key. +/// `nonce` is set when the server has supplied one; `ath` is the base64url +/// SHA-256 of the access token (required on token-bound requests). +pub fn dpop_proof( + key: &EcKey, + htm: &str, + htu: &str, + iat: i64, + nonce: Option<&str>, + access_token: Option<&str>, +) -> String { + let header = serde_json::json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": key.dpop_jwk() }); + let mut payload = serde_json::json!({ + "jti": random_token(), "htm": htm, "htu": htu, "iat": iat, + }); + if let Some(n) = nonce { + payload["nonce"] = serde_json::Value::String(n.to_string()); + } + if let Some(tok) = access_token { + payload["ath"] = serde_json::Value::String(b64u(&Sha256::digest(tok.as_bytes()))); + } + key.sign_jws(&header, &payload) +} + +// ── Metadata documents ─────────────────────────────────────────────────────── + +/// The client metadata document served at `client_id` (a stable HTTPS URL). +#[derive(Debug, Clone, Serialize)] +pub struct ClientMetadata { + pub client_id: String, + pub client_name: String, + pub client_uri: String, + pub redirect_uris: Vec, + pub grant_types: Vec, + pub response_types: Vec, + pub scope: String, + pub token_endpoint_auth_method: String, + pub token_endpoint_auth_signing_alg: String, + pub application_type: String, + pub dpop_bound_access_tokens: bool, + pub jwks_uri: String, +} + +impl ClientMetadata { + /// Confidential web client using `private_key_jwt` + DPoP (the atproto + /// recommendation for server-side apps). + pub fn confidential_web(base_url: &str, scope: &str) -> Self { + ClientMetadata { + client_id: format!("{base_url}/oauth/client-metadata.json"), + client_name: "Decoding Us".to_string(), + client_uri: base_url.to_string(), + redirect_uris: vec![format!("{base_url}/oauth/callback")], + grant_types: vec!["authorization_code".into(), "refresh_token".into()], + response_types: vec!["code".into()], + scope: scope.to_string(), + token_endpoint_auth_method: "private_key_jwt".into(), + token_endpoint_auth_signing_alg: "ES256".into(), + application_type: "web".into(), + dpop_bound_access_tokens: true, + jwks_uri: format!("{base_url}/oauth/jwks.json"), + } + } +} + +/// Authorization-server metadata (`/.well-known/oauth-authorization-server`). +#[derive(Debug, Clone, Deserialize)] +pub struct AuthServerMetadata { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub pushed_authorization_request_endpoint: Option, +} + +/// Protected-resource metadata (`/.well-known/oauth-protected-resource`) — names +/// the authorization server(s) for a PDS. +#[derive(Debug, Clone, Deserialize)] +pub struct ProtectedResourceMetadata { + #[serde(default)] + pub authorization_servers: Vec, +} + +/// Discover the authorization server for a PDS, then its metadata. +pub async fn discover_auth_server( + client: &reqwest::Client, + pds_url: &str, +) -> Result { + let prm: ProtectedResourceMetadata = client + .get(format!("{pds_url}/.well-known/oauth-protected-resource")) + .send() + .await? + .error_for_status()? + .json() + .await?; + let issuer = prm + .authorization_servers + .into_iter() + .next() + .ok_or_else(|| AtprotoError::Resolve("no authorization_servers for PDS".into()))?; + let meta: AuthServerMetadata = client + .get(format!("{issuer}/.well-known/oauth-authorization-server")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(meta) +} + +// ── Request builders (pure) ────────────────────────────────────────────────── + +/// PAR (pushed authorization request) form body. +#[allow(clippy::too_many_arguments)] +pub fn par_form( + client_id: &str, + redirect_uri: &str, + scope: &str, + state: &str, + code_challenge: &str, + login_hint: Option<&str>, + client_assertion_jwt: &str, +) -> Vec<(String, String)> { + let mut form = vec![ + ("response_type".into(), "code".into()), + ("client_id".into(), client_id.into()), + ("redirect_uri".into(), redirect_uri.into()), + ("scope".into(), scope.into()), + ("state".into(), state.into()), + ("code_challenge".into(), code_challenge.into()), + ("code_challenge_method".into(), "S256".into()), + ( + "client_assertion_type".into(), + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".into(), + ), + ("client_assertion".into(), client_assertion_jwt.into()), + ]; + if let Some(hint) = login_hint { + form.push(("login_hint".into(), hint.into())); + } + form +} + +/// Authorization-code token-exchange form body. +pub fn token_form( + client_id: &str, + redirect_uri: &str, + code: &str, + code_verifier: &str, + client_assertion_jwt: &str, +) -> Vec<(String, String)> { + vec![ + ("grant_type".into(), "authorization_code".into()), + ("code".into(), code.into()), + ("redirect_uri".into(), redirect_uri.into()), + ("client_id".into(), client_id.into()), + ("code_verifier".into(), code_verifier.into()), + ( + "client_assertion_type".into(), + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".into(), + ), + ("client_assertion".into(), client_assertion_jwt.into()), + ] +} + +/// Build the authorization redirect URL from a PAR `request_uri`. +pub fn authorize_url(authorization_endpoint: &str, client_id: &str, request_uri: &str) -> String { + let q = format!( + "client_id={}&request_uri={}", + urlencode(client_id), + urlencode(request_uri) + ); + let sep = if authorization_endpoint.contains('?') { '&' } else { '?' }; + format!("{authorization_endpoint}{sep}{q}") +} + +fn urlencode(s: &str) -> String { + // Minimal application/x-www-form-urlencoded for query values. + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::signature::Verifier; + + fn decode_part(part: &str) -> serde_json::Value { + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(part).unwrap()).unwrap() + } + + #[test] + fn pkce_matches_rfc7636_vector() { + // RFC 7636 Appendix B. + let p = Pkce::from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".into()); + assert_eq!(p.challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } + + #[test] + fn es256_jws_roundtrips_and_verifies() { + let key = EcKey::generate(); + let jwt = client_assertion(&key, "https://app.example/oauth/client-metadata.json", "https://pds.example", 1_700_000_000); + let parts: Vec<&str> = jwt.split('.').collect(); + assert_eq!(parts.len(), 3); + + let header = decode_part(parts[0]); + assert_eq!(header["alg"], "ES256"); + assert_eq!(header["kid"], key.thumbprint()); + let payload = decode_part(parts[1]); + assert_eq!(payload["iss"], "https://app.example/oauth/client-metadata.json"); + assert_eq!(payload["aud"], "https://pds.example"); + + // signature verifies against the public key over the signing input. + let signing_input = format!("{}.{}", parts[0], parts[1]); + let sig = Signature::from_slice(&URL_SAFE_NO_PAD.decode(parts[2]).unwrap()).unwrap(); + assert!(key.verifying_key().verify(signing_input.as_bytes(), &sig).is_ok()); + } + + #[test] + fn dpop_proof_has_jwk_htm_htu_ath() { + let key = EcKey::generate(); + let jwt = dpop_proof(&key, "POST", "https://pds.example/xrpc/com.atproto.server.getSession", 1_700_000_000, Some("srvnonce"), Some("access-tok")); + let parts: Vec<&str> = jwt.split('.').collect(); + let header = decode_part(parts[0]); + assert_eq!(header["typ"], "dpop+jwt"); + assert_eq!(header["jwk"]["crv"], "P-256"); + let payload = decode_part(parts[1]); + assert_eq!(payload["htm"], "POST"); + assert_eq!(payload["nonce"], "srvnonce"); + assert!(payload["ath"].is_string()); + } + + #[test] + fn ec_key_base64_roundtrips() { + let key = EcKey::generate(); + let b64 = key.to_base64(); + let restored = EcKey::from_base64(&b64).unwrap(); + assert_eq!(key.thumbprint(), restored.thumbprint()); + } + + #[test] + fn client_metadata_shape() { + let m = ClientMetadata::confidential_web("https://decoding-us.com", "atproto transition:generic"); + let v = serde_json::to_value(&m).unwrap(); + assert_eq!(v["client_id"], "https://decoding-us.com/oauth/client-metadata.json"); + assert_eq!(v["token_endpoint_auth_method"], "private_key_jwt"); + assert_eq!(v["dpop_bound_access_tokens"], true); + assert_eq!(v["redirect_uris"][0], "https://decoding-us.com/oauth/callback"); + } +} diff --git a/rust/crates/du-db/src/auth.rs b/rust/crates/du-db/src/auth.rs index f7f2340..0520880 100644 --- a/rust/crates/du-db/src/auth.rs +++ b/rust/crates/du-db/src/auth.rs @@ -35,6 +35,38 @@ pub async fn find_credential(pool: &PgPool, provider_key: &str) -> Result