From d3001612b2cf22565930740958b3a60df8c97d81 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 11 Oct 2026 20:06:26 +0530 Subject: [PATCH] PYQ clone: builder, CBT exam screen, grading, analytics dashboard Axum + SQLite full-stack copy of the ZenithPrep PYQ practice app. Paper builder with unseen-question exclusion, timed CBT screen with palette and autosave, +4/-1 grading with numerical tolerance, per-question analysis, history, dashboard with weak-subtopic tracking and coach notes. --- .gitignore | 1 + Cargo.lock | 2067 ++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 21 + README.md | 58 ++ content/privacy.html | 13 + content/terms.html | 13 + run_server.sh | 13 + src/analytics.rs | 242 +++++ src/auth.rs | 150 +++ src/db.rs | 117 +++ src/exam.rs | 311 +++++++ src/main.rs | 393 ++++++++ src/views.rs | 563 ++++++++++++ static/exam.js | 133 +++ static/style.css | 64 ++ tests/form_decode.rs | 23 + tests/grading.rs | 95 ++ 17 files changed, 4277 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 content/privacy.html create mode 100644 content/terms.html create mode 100755 run_server.sh create mode 100644 src/analytics.rs create mode 100644 src/auth.rs create mode 100644 src/db.rs create mode 100644 src/exam.rs create mode 100644 src/main.rs create mode 100644 src/views.rs create mode 100644 static/exam.js create mode 100644 static/style.css create mode 100644 tests/form_decode.rs create mode 100644 tests/grading.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d403be7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2067 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[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 = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +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 = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +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", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +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.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[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.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[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.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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 = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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", + "smallvec", + "windows-link", +] + +[[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 = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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 = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_json" +version = "1.0.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1741ab7a6cc54a03a89b5d563ed60075c277d9e3cfa73ad0c1f23f23974703c6" +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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", + "uuid", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "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 = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[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-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "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", +] + +[[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.119", +] + +[[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 = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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 = "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.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom", + "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 = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.7", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[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.119", +] + +[[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.119", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure", +] + +[[package]] +name = "zenith-pyq-clone" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "serde", + "serde_json", + "serde_urlencoded", + "sqlx", + "tokio", + "tower-cookies", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..84483fc --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "zenith-pyq-clone" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.104" +axum = { version = "0.8.9", features = ["form", "query", "json", "http1", "http2"] } +chrono = { version = "0.4.45", features = ["serde"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.152" +sqlx = { version = "0.9.0", features = ["sqlite", "runtime-tokio", "macros", "chrono", "uuid"] } +tokio = { version = "1.53.2", features = ["full"] } +tower-cookies = "0.11.0" +tower-http = { version = "0.7.1", features = ["fs"] } +tracing = "0.1.44" +tracing-subscriber = "0.3.23" +uuid = { version = "1.28.0", features = ["v4", "serde"] } + +[dev-dependencies] +serde_urlencoded = "0.7.1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..d030f92 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# PYQ clone + +A working full-stack copy of the ZenithPrep PYQ practice app: build a paper from +JEE Main style questions, take it on a timed CBT screen with +4 / −1 marking, +then read the analysis and progress dashboard. + +Rust + Axum + SQLite. Server-rendered HTML, vanilla JS, no frontend framework. + +## Run + + cargo run + +Then open http://127.0.0.1:8080 and sign in with any name and email. + +Environment: + +| var | default | meaning | +|------------|--------------------|------------------------------| +| `PYQ_ADDR` | `127.0.0.1:8080` | listen address | +| `PYQ_DB` | `pyq.db` | SQLite file, created if absent | +| `PYQ_BASE` | empty | URL prefix when mounted in a subpath | + +## Routes + +| route | what it does | +|---------------------------|-----------------------------------------------| +| `GET /` | landing page with live bank counts | +| `GET /student`, `POST /student` | local sign-in, sets an HttpOnly session cookie | +| `GET /logout` | clears the session | +| `GET /build`, `POST /build` | chapter picker and paper generation | +| `POST /attempt/:paper` | starts or resumes an attempt | +| `GET /attempt/:paper` | the CBT screen | +| `POST /attempt/:id/answer` | JSON autosave of one answer | +| `POST /attempt/:id/submit` | grades and closes the paper | +| `GET /result/:attempt` | score, accuracy and per-question analysis | +| `GET /history` | every submitted paper | +| `GET /dashboard` | cross-paper trend, weak subtopics, coach notes | +| `GET /s/:token` | public paper view, never a score | +| `GET /api/attempt/:id/state` | autosaved answers as JSON | + +## Marking + ++4 for a correct answer, −1 for a wrong one, 0 for a blank. Numerical answers +compare against a 0.1 percent tolerance so `20.01` is accepted for `20`. + +## Question selection + +A paper is drawn from the chosen chapters, shuffled with a seed derived from the +request, and trimmed to the requested length. With "unseen questions only" the +query excludes anything already answered by that user, so the same question does +not come back by accident. + +## Tests + + cargo test + +Covers the marking rules, numerical tolerance, and the urlencoded form decoding +that shapes the build endpoint. diff --git a/content/privacy.html b/content/privacy.html new file mode 100644 index 0000000..37cb83c --- /dev/null +++ b/content/privacy.html @@ -0,0 +1,13 @@ +

What is stored, where it lives, and what you can do about it.

+

What is stored

+

Your name and email, the papers you build, your answers to each question, how long you spent on each one, and the score of every submitted paper.

+

Where it lives

+

Everything is written to a SQLite file on the machine running this service. Nothing is sent to a third party. There is no analytics script, no tracking pixel, and no external font request beyond the Google Fonts stylesheet used for typography.

+

What is never collected

+

No contacts, no location, no device identifiers, no advertising profile.

+

Who can read it

+

Anyone with access to the database file or to a signed-in browser session. Sessions are held in an HttpOnly cookie scoped to this site.

+

Deleting your data

+

Delete your account and every paper, answer and result attached to it by removing your row from the users table. Nothing is retained elsewhere, so that removal is complete.

+

Questions bank

+

Question content is study material held in the same database. It is not personal data and is not linked to you beyond the papers you build from it.

\ No newline at end of file diff --git a/content/terms.html b/content/terms.html new file mode 100644 index 0000000..9ae8be5 --- /dev/null +++ b/content/terms.html @@ -0,0 +1,13 @@ +

This is a study tool built as a working copy of an existing practice site. Using it means you accept these terms.

+

What the service does

+

It builds practice papers from a bank of JEE Main style questions, runs them on a timed screen with +4 and −1 marking, and keeps your submitted papers so you can review results.

+

Your account

+

Sign-in is local to this installation. There is no password: anyone who knows the email can attach papers to that account. Do not use it for anything you would mind losing.

+

Question accuracy

+

Questions, answers and solutions are study material and may contain errors. Treat a wrong answer key as a bug, not a fact, and check anything you plan to rely on.

+

Acceptable use

+

No scraping at scale, no automated bulk paper generation, no attempts to reach other students' papers. Personal practice only.

+

No warranty

+

The service is provided as is, with no promise that it will be available or error free.

+

Changes

+

Terms can change as the tool does. The version in this file is the one that applies.

\ No newline at end of file diff --git a/run_server.sh b/run_server.sh new file mode 100755 index 0000000..71728d0 --- /dev/null +++ b/run_server.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Starts the clone detached so it survives shell teardown. Usage: run_server.sh +set -e +DIR="/home/oiupoyt/my stuff yea/projects/zenith-pyq-clone" +PORT="${1:-8123}" +DB="${2:-/tmp/pyq_run.db}" +cd "$DIR" +rm -f "$DB" "$DB-wal" "$DB-shm" +PYQ_DB="$DB" PYQ_ADDR="127.0.0.1:$PORT" RUST_LOG=info \ + setsid nohup ./target/debug/zenith-pyq-clone > "/tmp/pyq_server_$PORT.log" 2>&1 < /dev/null & +echo "started pid=$! port=$PORT db=$DB" +sleep 3 +curl -s -o /dev/null -w "health=%{http_code}\n" "http://127.0.0.1:$PORT/" \ No newline at end of file diff --git a/src/analytics.rs b/src/analytics.rs new file mode 100644 index 0000000..f5de56c --- /dev/null +++ b/src/analytics.rs @@ -0,0 +1,242 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use sqlx::{Row, SqlitePool}; + +use crate::exam::{self, Graded}; + +#[derive(Clone)] +pub struct Bucket { + pub label: String, + pub right: i32, + pub wrong: i32, + pub skipped: i32, +} + +impl Bucket { + pub fn total(&self) -> i32 { + self.right + self.wrong + self.skipped + } + pub fn pct(&self) -> i32 { + if self.total() == 0 { 0 } else { (self.right as f64 / self.total() as f64 * 100.0).round() as i32 } + } + pub fn lost(&self) -> i32 { + self.wrong + self.skipped + } + pub fn status(&self) -> &'static str { + let t = self.total(); + if t == 0 { + return "Good"; + } + let lost = self.lost() as f64 / t as f64; + if lost >= 0.5 { + "Weak" + } else if lost >= 0.3 { + "Revise" + } else { + "Good" + } + } +} + +#[allow(dead_code)] +pub struct Analysis { + pub score: i32, + pub max_score: i32, + pub used_secs: i64, + pub right: i32, + pub wrong: i32, + pub skipped: i32, + pub by_subject: Vec, + pub by_difficulty: Vec, + pub by_topic: Vec<(Bucket, String)>, + pub mistakes: Vec, +} + +pub fn accuracy(right: i32, wrong: i32) -> i32 { + if right + wrong == 0 { 0 } else { (right as f64 / (right + wrong) as f64 * 100.0).round() as i32 } +} + +pub fn analyse(rows: &[Graded], score: i32, max_score: i32, used_secs: i64) -> Analysis { + let (mut right, mut wrong, mut skipped) = (0, 0, 0); + let mut subj: Vec = Vec::new(); + let mut diff: Vec = Vec::new(); + let mut topic: HashMap<(String, String), Bucket> = HashMap::new(); + let mut mistakes: Vec = Vec::new(); + + for r in rows { + let attempted = !r.selected.trim().is_empty(); + if attempted && r.correct { + right += 1; + } else if attempted { + wrong += 1; + mistakes.push((*r).clone()); + } else { + skipped += 1; + } + for (list, key) in [ + (&mut subj, r.q.subject.clone()), + (&mut diff, r.q.difficulty.clone()), + ] { + match list.iter_mut().find(|b| b.label == key) { + Some(b) => { + if attempted && r.correct { b.right += 1 } else if attempted { b.wrong += 1 } else { b.skipped += 1 } + } + None => { + let mut b = Bucket { label: key, right: 0, wrong: 0, skipped: 0 }; + if attempted && r.correct { b.right = 1 } else if attempted { b.wrong = 1 } else { b.skipped = 1 } + list.push(b); + } + } + } + let key = (r.q.chapter.clone(), r.q.subtopic.clone()); + let e = topic.entry(key).or_insert_with(|| Bucket { label: r.q.subtopic.clone(), right: 0, wrong: 0, skipped: 0 }); + if attempted && r.correct { e.right += 1 } else if attempted { e.wrong += 1 } else { e.skipped += 1 } + } + subj.sort_by(|a, b| a.label.cmp(&b.label)); + diff.sort_by_key(|b| match b.label.as_str() { "Easy" => 0, "Medium" => 1, _ => 2 }); + let mut by_topic: Vec<(Bucket, String)> = topic.into_iter().map(|((_, s), b)| (b, s)).collect(); + by_topic.sort_by_key(|(b, _)| (-b.lost(), b.label.clone())); + + Analysis { + score, + max_score, + used_secs, + right, + wrong, + skipped, + by_subject: subj, + by_difficulty: diff, + by_topic, + mistakes, + } +} + +pub struct TestRow { + pub attempt_id: String, + pub started_at: DateTime, + pub title: String, + pub score: i32, + pub max_score: i32, + pub used_secs: i64, + pub right: i32, + pub wrong: i32, +} + +pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result> { + let rows = sqlx::query( + "SELECT a.id, a.started_at, a.score, a.max_score, a.used_secs, p.title FROM attempts a + JOIN papers p ON p.id=a.paper_id WHERE a.user_id=? AND a.submitted_at IS NOT NULL ORDER BY a.submitted_at DESC", + ) + .bind(uid) + .fetch_all(pool) + .await?; + let mut out = Vec::new(); + for r in &rows { + let aid: String = r.get("id"); + let (g, right, wrong) = exam::history_counts(pool, &aid).await?; + out.push(TestRow { + attempt_id: aid, + started_at: r.get::("started_at").parse().unwrap_or_else(|_| Utc::now()), + title: r.get("title"), + score: r.get("score"), + max_score: r.get("max_score"), + used_secs: r.get("used_secs"), + right, + wrong, + }); + let _ = g; + } + Ok(out) +} + +#[allow(dead_code)] +pub struct Trend { + pub right: i32, + pub wrong: i32, + pub skipped: i32, + pub seen: i32, +} + +pub async fn weakest(pool: &SqlitePool, uid: &str) -> anyhow::Result> { + let rows = sqlx::query( + "SELECT c.name AS chapter, s.name AS subtopic, q.answer, a.selected, q.qtype + FROM answers a JOIN attempts at ON at.id=a.attempt_id JOIN paper_questions pq ON pq.paper_id=at.paper_id AND pq.question_id=a.question_id + JOIN questions q ON q.id=a.question_id JOIN chapters c ON c.id=q.chapter_id JOIN subtopics s ON s.id=q.subtopic_id + WHERE at.user_id=? AND at.submitted_at IS NOT NULL", + ) + .bind(uid) + .fetch_all(pool) + .await?; + let mut m: HashMap<(String, String), Bucket> = HashMap::new(); + for r in &rows { + let sel: String = r.get("selected"); + let ans: String = r.get("answer"); + let qtype: String = r.get("qtype"); + let attempted = !sel.trim().is_empty(); + let ok = match qtype.as_str() { + "NUM" => exam::numerical_ok(&sel, &ans), + _ => attempted && sel.trim().eq_ignore_ascii_case(ans.trim()), + }; + let chapter: String = r.get("chapter"); + let subtopic: String = r.get("subtopic"); + let e = m.entry((chapter, subtopic.clone())).or_insert_with(|| Bucket { label: subtopic, right: 0, wrong: 0, skipped: 0 }); + if attempted && ok { e.right += 1 } else if attempted { e.wrong += 1 } else { e.skipped += 1 } + } + let mut v: Vec<(Bucket, String, String)> = m.into_iter().map(|((c, s), b)| (b, c, s)).collect(); + v.sort_by_key(|(b, _, _)| (-b.lost(), b.label.clone())); + v.truncate(8); + Ok(v) +} + +pub fn coach_notes(a: &Analysis, history: &[TestRow]) -> Vec { + let mut notes = Vec::new(); + if a.score == 0 && a.right == 0 { + notes.push("No question in this paper was answered correctly. Start with the chapters you just revised, one at a time, and use unseen-only so nothing is repeated.".into()); + } else { + notes.push(format!( + "You scored {} of {} ({}% correct) with {} wrong and {} skipped in {}.", + a.score, + a.max_score, + accuracy(a.right, a.wrong), + a.wrong, + a.skipped, + fmt_dur(a.used_secs) + )); + } + if let Some((w, name)) = a.by_topic.iter().find(|(b, _)| b.lost() > 0) { + notes.push(format!("Largest mark loss: {} ({} of {} questions lost). Revise this before another full paper.", name, w.lost(), w.total())); + } + if let Some(h) = a.by_difficulty.iter().find(|b| b.label == "Hard") { + if h.total() > 0 && h.pct() < 40 { + notes.push(format!("Hard questions: {}% correct. Spend a full sitting on hard-only papers before mixing again.", h.pct())); + } + } + if history.len() >= 2 { + let first = history.last().unwrap(); + let last = history.first().unwrap(); + if last.max_score > 0 && first.max_score > 0 { + let p1 = first.score as f64 / first.max_score as f64 * 100.0; + let p2 = last.score as f64 / last.max_score as f64 * 100.0; + notes.push(format!( + "Across {} tests your score moved from {}% to {}%. {}", + history.len(), + p1.round() as i32, + p2.round() as i32, + if p2 >= p1 { "The trend is up." } else { "The trend is down, so the recent papers targeted something that needs another pass." } + )); + } + } + if a.skipped > a.right { + notes.push(format!("{} questions were left blank against {} answered right. Time management, not knowledge, is costing you marks here.", a.skipped, a.right)); + } + notes +} + +pub fn fmt_dur(s: i64) -> String { + format!("{}h {:02}m {:02}s", s / 3600, (s % 3600) / 60, s % 60) +} + +pub fn fmt_pct(score: i32, max: i32) -> i32 { + if max == 0 { 0 } else { (score as f64 / max as f64 * 100.0).round() as i32 } +} \ No newline at end of file diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..f30a193 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,150 @@ +use axum::extract::{Form, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{Html, IntoResponse}; +use chrono::Utc; +use serde::Deserialize; +use sqlx::SqlitePool; +use std::sync::Arc; + +use crate::views; + +pub const COOKIE: &str = "pyq_session"; + +pub struct Ctx { + pub pool: SqlitePool, + pub base: String, +} + +impl Ctx { + /// Returns the signed-in user id and display name, or a redirect to sign-in. + pub async fn require_user(&self, h: &HeaderMap) -> Result<(String, String), axum::response::Response> { + match user_id(&self.pool, h).await { + Some(id) => { + let name: String = sqlx::query_scalar("SELECT name FROM users WHERE id=?") + .bind(&id) + .fetch_one(&self.pool) + .await + .unwrap_or_else(|_| "Student".into()); + Ok((id, name)) + } + None => Err(redirect(&format!("{}/student", self.base))), + } + } +} + +pub async fn user_id(pool: &SqlitePool, h: &HeaderMap) -> Option { + let raw = h.get(COOKIE).and_then(|v| v.to_str().ok()).or_else(|| { + h.get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + })?; + let mut token: Option<&str> = None; + for part in raw.split(';') { + if let Some((k, v)) = part.trim().split_once('=') + && k == COOKIE + { + token = Some(v); + } + } + let token = token?; + tracing::debug!("session token={token}"); + match sqlx::query_scalar::<_, String>("SELECT user_id FROM sessions WHERE token=?") + .bind(token) + .fetch_optional(pool) + .await + { + Ok(found) => { + tracing::debug!("session lookup -> {:?}", found); + found + } + Err(e) => { + tracing::error!("session lookup failed: {e}"); + None + } + } +} + +pub fn redirect(to: &str) -> axum::response::Response { + axum::response::Response::builder() + .status(StatusCode::FOUND) + .header(axum::http::header::LOCATION, to) + .body(axum::body::Body::empty()) + .expect("static redirect response") +} + +pub fn html_page(body: String, code: StatusCode) -> axum::response::Response { + (code, Html(body)).into_response() +} + +pub async fn student_post(State(ctx): State>, Form(f): Form) -> axum::response::Response { + let name = f.name.trim(); + let email = f.email.trim().to_lowercase(); + let next = f + .next + .filter(|n| n.starts_with('/') && !n.contains("..")) + .unwrap_or_else(|| "/build".into()); + + if name.is_empty() || name.chars().count() > 80 { + return html_page(views::student(&ctx, &next, Some("Enter the name your account shows.")), StatusCode::BAD_REQUEST); + } + if !email.contains('@') || email.len() > 120 { + return html_page(views::student(&ctx, &next, Some("That email address does not look right.")), StatusCode::BAD_REQUEST); + } + + let candidate = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO users(id,name,email,created_at) VALUES(?,?,?,?) ON CONFLICT(email) DO UPDATE SET name=excluded.name") + .bind(&candidate) + .bind(name) + .bind(&email) + .bind(Utc::now().to_rfc3339()) + .execute(&ctx.pool) + .await + .ok(); + let uid: String = sqlx::query_scalar("SELECT id FROM users WHERE email=?") + .bind(&email) + .fetch_one(&ctx.pool) + .await + .unwrap_or(candidate); + + let token = uuid::Uuid::new_v4().simple().to_string(); + sqlx::query("INSERT INTO sessions(token,user_id,created_at) VALUES(?,?,?)") + .bind(&token) + .bind(&uid) + .bind(Utc::now().to_rfc3339()) + .execute(&ctx.pool) + .await + .ok(); + + let cookie = format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000"); + axum::response::Response::builder() + .status(StatusCode::FOUND) + .header(axum::http::header::LOCATION, format!("{}{}", ctx.base, next)) + .header(axum::http::header::SET_COOKIE, cookie) + .body(axum::body::Body::empty()) + .expect("static redirect response") +} + +#[derive(Deserialize)] +pub struct StudentForm { + pub name: String, + pub email: String, + #[serde(default)] + pub next: Option, +} + +pub async fn logout(State(ctx): State>, h: HeaderMap) -> axum::response::Response { + if let Some(uid) = user_id(&ctx.pool, &h).await { + tracing::info!("sign out for {uid}"); + } + if let Some(raw) = h.get(COOKIE).and_then(|c| c.to_str().ok()) + && let Some((_, v)) = raw.split_once('=') + { + let token = v.split(';').next().unwrap_or_default(); + sqlx::query("DELETE FROM sessions WHERE token=?").bind(token).execute(&ctx.pool).await.ok(); + } + let mut r = redirect(&format!("{}/", ctx.base)); + r.headers_mut().insert( + axum::http::header::SET_COOKIE, + format!("{COOKIE}=; Path=/; HttpOnly; Max-Age=0").parse().expect("valid cookie"), + ); + r +} \ No newline at end of file diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..0051109 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,117 @@ +use sqlx::SqlitePool; + +pub async fn init_db(pool: &SqlitePool) -> anyhow::Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, created_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS chapters(id INTEGER PRIMARY KEY, subject TEXT NOT NULL, name TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS subtopics(id INTEGER PRIMARY KEY, chapter_id INTEGER NOT NULL REFERENCES chapters(id), name TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS questions(id INTEGER PRIMARY KEY, subject TEXT NOT NULL, chapter_id INTEGER NOT NULL, subtopic_id INTEGER NOT NULL, qtype TEXT NOT NULL, stem TEXT NOT NULL, opt_a TEXT, opt_b TEXT, opt_c TEXT, opt_d TEXT, answer TEXT NOT NULL, difficulty TEXT NOT NULL, year INTEGER NOT NULL, shift TEXT NOT NULL, solution TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS papers(id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, duration_min INTEGER NOT NULL, created_at TEXT NOT NULL, share_token TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS paper_questions(paper_id TEXT NOT NULL, question_id INTEGER NOT NULL, ord INTEGER NOT NULL, PRIMARY KEY(paper_id, question_id)); + CREATE TABLE IF NOT EXISTS attempts(id TEXT PRIMARY KEY, paper_id TEXT NOT NULL, user_id TEXT NOT NULL, started_at TEXT NOT NULL, submitted_at TEXT, score INTEGER DEFAULT 0, max_score INTEGER DEFAULT 0, used_secs INTEGER DEFAULT 0); + CREATE TABLE IF NOT EXISTS answers(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, selected TEXT DEFAULT '', marked_review INTEGER DEFAULT 0, time_secs INTEGER DEFAULT 0, PRIMARY KEY(attempt_id, question_id)); + CREATE TABLE IF NOT EXISTS mistake_tags(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, tag TEXT NOT NULL, PRIMARY KEY(attempt_id, question_id, tag)); + CREATE TABLE IF NOT EXISTS sessions(token TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS seen_questions(user_id TEXT NOT NULL, question_id INTEGER NOT NULL, PRIMARY KEY(user_id, question_id));", + ) + .execute(pool) + .await?; + let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM questions") + .fetch_one(pool) + .await?; + if n == 0 { + seed(pool).await?; + } + Ok(()) +} + +struct SeedQ<'a> { + subject: &'a str, + chapter: &'a str, + subtopic: &'a str, + qtype: &'a str, + stem: &'a str, + opts: [&'a str; 4], + answer: &'a str, + difficulty: &'a str, + year: i64, + shift: &'a str, + solution: &'a str, +} + +pub async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { + let chapters: Vec<(&str, &str, Vec<&str>)> = vec![ + ("Physics", "Mechanics", vec!["Kinematics", "Laws of Motion", "Work Energy Power"]), + ("Physics", "Electromagnetism", vec!["Electrostatics", "Current Electricity"]), + ("Physics", "Optics", vec!["Ray Optics", "Wave Optics"]), + ("Chemistry", "Physical Chemistry", vec!["Mole Concept", "Chemical Kinetics"]), + ("Chemistry", "Organic Chemistry", vec!["GOC", "Aldehydes and Ketones"]), + ("Chemistry", "Inorganic Chemistry", vec!["Periodic Table", "Coordination Compounds"]), + ("Maths", "Algebra", vec!["Quadratic Equations", "Matrices and Determinants"]), + ("Maths", "Calculus", vec!["Limits and Continuity", "Definite Integration"]), + ("Maths", "Geometry", vec!["Straight Lines", "Parabola"]), + ]; + for (subj, ch, subs) in &chapters { + use sqlx::Row; + let id: i64 = sqlx::query("INSERT INTO chapters(subject,name) VALUES(?,?) RETURNING id") + .bind(subj) + .bind(ch) + .fetch_one(pool) + .await? + .get(0); + for s in subs { + sqlx::query("INSERT INTO subtopics(chapter_id,name) VALUES(?,?)") + .bind(id) + .bind(s) + .execute(pool) + .await?; + } + } + let qs: Vec = vec![ + SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Kinematics",qtype:"MCQ",stem:"A particle starts from rest with constant acceleration 2 m/s2. Distance covered in the 5th second is:",opts:["9 m","8 m","10 m","5 m"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"Sn = u + a(2n-1)/2 = 0 + 2(9)/2 = 9 m."}, + SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Kinematics",qtype:"MCQ",stem:"Velocity-time graph is a straight line parallel to time axis. The motion is:",opts:["uniform velocity","uniform acceleration","non-uniform velocity","at rest"],answer:"A",difficulty:"Easy",year:2023,shift:"S2",solution:"Slope zero means acceleration zero, velocity constant."}, + SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Laws of Motion",qtype:"MCQ",stem:"A 2 kg block on a frictionless surface is pulled by 10 N. Its acceleration is:",opts:["5 m/s2","20 m/s2","2 m/s2","0.2 m/s2"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"a = F/m = 10/2 = 5 m/s2."}, + SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Work Energy Power",qtype:"NUM",stem:"A 1 kg mass drops from 20 m (g=10). Speed just before impact in m/s is:",opts:["","","",""],answer:"20",difficulty:"Medium",year:2022,shift:"S1",solution:"v = sqrt(2gh) = sqrt(400) = 20 m/s."}, + SeedQ{subject:"Physics",chapter:"Electromagnetism",subtopic:"Electrostatics",qtype:"MCQ",stem:"Force between two point charges doubles the distance. The force becomes:",opts:["one-fourth","half","double","same"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"Coulomb force scales as 1/r2."}, + SeedQ{subject:"Physics",chapter:"Electromagnetism",subtopic:"Current Electricity",qtype:"MCQ",stem:"A wire of resistance R is stretched to double length. New resistance is:",opts:["4R","2R","R/2","R"],answer:"A",difficulty:"Medium",year:2024,shift:"S2",solution:"R = rho l/A; volume constant gives R proportional to l2, so 4R."}, + SeedQ{subject:"Physics",chapter:"Optics",subtopic:"Ray Optics",qtype:"MCQ",stem:"Focal length of an equiconvex lens (n=1.5, R=20 cm) is:",opts:["20 cm","40 cm","10 cm","30 cm"],answer:"A",difficulty:"Medium",year:2022,shift:"S2",solution:"1/f = (n-1)(2/R) = 0.5(2/20) = 1/20."}, + SeedQ{subject:"Physics",chapter:"Optics",subtopic:"Wave Optics",qtype:"MCQ",stem:"In YDSE, fringe width is proportional to:",opts:["wavelength","1/wavelength","screen distance squared","slit width"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"beta = lambda D/d."}, + SeedQ{subject:"Chemistry",chapter:"Physical Chemistry",subtopic:"Mole Concept",qtype:"MCQ",stem:"Moles in 4 g of NaOH (M=40) are:",opts:["0.1","0.4","1","4"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"n = 4/40 = 0.1."}, + SeedQ{subject:"Chemistry",chapter:"Physical Chemistry",subtopic:"Chemical Kinetics",qtype:"MCQ",stem:"For a first order reaction, t1/2 = 10 min. Rate constant k is:",opts:["0.0693 min-1","0.693 min-1","10 min-1","1 min-1"],answer:"A",difficulty:"Medium",year:2023,shift:"S2",solution:"k = 0.693/t1/2 = 0.0693 min-1."}, + SeedQ{subject:"Chemistry",chapter:"Organic Chemistry",subtopic:"GOC",qtype:"MCQ",stem:"Most stable carbocation among: methyl, ethyl, isopropyl, tert-butyl:",opts:["tert-butyl","methyl","ethyl","isopropyl"],answer:"A",difficulty:"Easy",year:2022,shift:"S1",solution:"Hyperconjugation plus inductive effect: 3 degree most stable."}, + SeedQ{subject:"Chemistry",chapter:"Organic Chemistry",subtopic:"Aldehydes and Ketones",qtype:"MCQ",stem:"Tollens reagent gives silver mirror with:",opts:["aldehydes","ketones","alkenes","ethers"],answer:"A",difficulty:"Easy",year:2024,shift:"S2",solution:"Aldehydes reduce Tollens; ketones do not."}, + SeedQ{subject:"Chemistry",chapter:"Inorganic Chemistry",subtopic:"Periodic Table",qtype:"MCQ",stem:"Highest first ionisation enthalpy among second period:",opts:["Ne","Li","B","O"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"Noble gas Ne has maximum IE1 in period 2."}, + SeedQ{subject:"Chemistry",chapter:"Inorganic Chemistry",subtopic:"Coordination Compounds",qtype:"MCQ",stem:"Oxidation state of Fe in [Fe(CN)6]4- is:",opts:["+2","+3","+4","0"],answer:"A",difficulty:"Medium",year:2024,shift:"S1",solution:"x + 6(-1) = -4 gives x = +2."}, + SeedQ{subject:"Maths",chapter:"Algebra",subtopic:"Quadratic Equations",qtype:"MCQ",stem:"Roots of x2 - 5x + 6 = 0 are:",opts:["2, 3","1, 6","-2, -3","5, 1"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"Factors (x-2)(x-3) = 0."}, + SeedQ{subject:"Maths",chapter:"Algebra",subtopic:"Matrices and Determinants",qtype:"NUM",stem:"Determinant of [[2,0],[0,3]] is:",opts:["","","",""],answer:"6",difficulty:"Easy",year:2023,shift:"S1",solution:"Diagonal product 2x3 = 6."}, + SeedQ{subject:"Maths",chapter:"Calculus",subtopic:"Limits and Continuity",qtype:"MCQ",stem:"lim x->0 sin(x)/x equals:",opts:["1","0","infinity","-1"],answer:"A",difficulty:"Easy",year:2022,shift:"S2",solution:"Standard limit equals 1."}, + SeedQ{subject:"Maths",chapter:"Calculus",subtopic:"Definite Integration",qtype:"MCQ",stem:"Integral 0 to pi of sin(x) dx equals:",opts:["2","0","1","pi"],answer:"A",difficulty:"Medium",year:2024,shift:"S2",solution:"[-cos x]0^pi = 1 + 1 = 2."}, + SeedQ{subject:"Maths",chapter:"Geometry",subtopic:"Straight Lines",qtype:"MCQ",stem:"Slope of line 2x + 3y = 6 is:",opts:["-2/3","2/3","3/2","-3/2"],answer:"A",difficulty:"Easy",year:2023,shift:"S2",solution:"y = -2x/3 + 2, slope -2/3."}, + SeedQ{subject:"Maths",chapter:"Geometry",subtopic:"Parabola",qtype:"MCQ",stem:"Focus of y2 = 8x is:",opts:["(2,0)","(0,2)","(4,0)","(8,0)"],answer:"A",difficulty:"Medium",year:2024,shift:"S1",solution:"4a = 8 gives a = 2, focus (a,0)."}, + SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Laws of Motion",qtype:"MCQ",stem:"A car rounds a curve at constant speed. Its acceleration is:",opts:["centripetal, nonzero","zero","tangential only","infinite"],answer:"A",difficulty:"Medium",year:2023,shift:"S2",solution:"Direction changes so velocity changes; centripetal acceleration nonzero."}, + SeedQ{subject:"Chemistry",chapter:"Physical Chemistry",subtopic:"Mole Concept",qtype:"NUM",stem:"Molarity of 0.5 mol solute in 2 L solution is (mol/L):",opts:["","","",""],answer:"0.25",difficulty:"Easy",year:2022,shift:"S1",solution:"M = 0.5/2 = 0.25."}, + SeedQ{subject:"Maths",chapter:"Algebra",subtopic:"Quadratic Equations",qtype:"MCQ",stem:"Discriminant of x2 + 4x + 4 is:",opts:["0","16","-16","4"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"D = 16 - 16 = 0, equal roots."}, + SeedQ{subject:"Maths",chapter:"Calculus",subtopic:"Definite Integration",qtype:"NUM",stem:"Integral 0 to 1 of 2x dx equals:",opts:["","","",""],answer:"1",difficulty:"Easy",year:2022,shift:"S1",solution:"[x2]0^1 = 1."}, + ]; + use sqlx::Row; + for q in &qs { + let ch_id: i64 = sqlx::query("SELECT id FROM chapters WHERE subject=? AND name=?") + .bind(q.subject) + .bind(q.chapter) + .fetch_one(pool) + .await? + .get(0); + let st_id: i64 = sqlx::query("SELECT id FROM subtopics WHERE chapter_id=? AND name=?") + .bind(ch_id) + .bind(q.subtopic) + .fetch_one(pool) + .await? + .get(0); + sqlx::query("INSERT INTO questions(subject,chapter_id,subtopic_id,qtype,stem,opt_a,opt_b,opt_c,opt_d,answer,difficulty,year,shift,solution) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)") + .bind(q.subject).bind(ch_id).bind(st_id).bind(q.qtype).bind(q.stem) + .bind(q.opts[0]).bind(q.opts[1]).bind(q.opts[2]).bind(q.opts[3]) + .bind(q.answer).bind(q.difficulty).bind(q.year).bind(q.shift).bind(q.solution) + .execute(pool).await?; + } + Ok(()) +} diff --git a/src/exam.rs b/src/exam.rs new file mode 100644 index 0000000..be3d9b4 --- /dev/null +++ b/src/exam.rs @@ -0,0 +1,311 @@ +use std::collections::HashMap; + +use chrono::Utc; +use serde::Deserialize; +use sqlx::{Row, SqlitePool}; + +pub const CORRECT: i32 = 4; +pub const NEGATIVE: i32 = -1; + +#[derive(Clone)] +pub struct Question { + pub id: i64, + pub subject: String, + pub chapter: String, + pub subtopic: String, + pub qtype: String, + pub stem: String, + pub opts: [String; 4], + pub answer: String, + pub difficulty: String, + pub year: i64, + pub shift: String, + pub solution: String, +} + +pub fn qrow(r: &sqlx::sqlite::SqliteRow) -> Question { + let g = |i: &str| -> String { r.try_get::, _>(i).ok().flatten().unwrap_or_default() }; + Question { + id: r.get("id"), + subject: r.get("subject"), + chapter: g("chapter"), + subtopic: g("subtopic"), + qtype: r.get("qtype"), + stem: r.get("stem"), + opts: [g("opt_a"), g("opt_b"), g("opt_c"), g("opt_d")], + answer: r.get("answer"), + difficulty: r.get("difficulty"), + year: r.get("year"), + shift: r.get("shift"), + solution: g("solution"), + } +} + +/// `chapters` arrives comma-separated: a urlencoded body with repeated `chapters=1&chapters=4` +/// keys keeps only the last value, so the form posts one list field instead. +#[derive(Deserialize)] +pub struct BuildForm { + #[serde(default)] + pub chapters: String, + pub count: Option, + pub duration: Option, + pub unseen_only: Option, +} + +impl BuildForm { + fn chapter_ids(&self) -> Vec { + self.chapters.split(',').filter_map(|c| c.trim().parse().ok()).collect() + } +} + +pub async fn build_paper(pool: &SqlitePool, uid: &str, f: &BuildForm) -> anyhow::Result<(String, String)> { + let chapters = f.chapter_ids(); + if chapters.is_empty() { + anyhow::bail!("Pick at least one chapter"); + } + let count = f.count.unwrap_or(12).clamp(3, 45); + let duration = f.duration.unwrap_or(count as i64 * 2).clamp(5, 180); + let unseen = f.unseen_only.as_deref() == Some("on"); + + let mut ids: Vec = Vec::new(); + for ch in &chapters { + // sqlx requires a static SQL string for queries that bind parameters; unseen-exclusion is a separate branch. + let rows = if unseen { + sqlx::query( + "SELECT q.id FROM questions q WHERE q.chapter_id=?\n AND q.id NOT IN (SELECT question_id FROM seen_questions WHERE user_id=?)", + ) + .bind(ch) + .bind(uid) + .fetch_all(pool) + .await? + } else { + sqlx::query("SELECT q.id FROM questions q WHERE q.chapter_id=?") + .bind(ch) + .fetch_all(pool) + .await? + }; + for r in rows { + ids.push(r.get::("id")); + } + } + if ids.is_empty() { + anyhow::bail!("No questions left for that selection. Turn off 'unseen only' to reuse questions."); + } + // Deterministic shuffle keyed on the request, so a reload rebuilds an equivalent paper. + let seed = uid.len() as u64 ^ chapters.iter().sum::() as u64 ^ count as u64; + shuffle(&mut ids, seed); + ids.truncate(count); + + let paper = uuid::Uuid::new_v4().to_string(); + let share = uuid::Uuid::new_v4().simple().to_string()[..10].to_string(); + let title = format!("Mixed paper · {count} questions"); + sqlx::query("INSERT INTO papers(id,user_id,title,duration_min,created_at,share_token) VALUES(?,?,?,?,?,?)") + .bind(&paper) + .bind(uid) + .bind(&title) + .bind(duration) + .bind(Utc::now().to_rfc3339()) + .bind(&share) + .execute(pool) + .await?; + for (i, qid) in ids.iter().enumerate() { + sqlx::query("INSERT INTO paper_questions(paper_id,question_id,ord) VALUES(?,?,?)") + .bind(&paper) + .bind(qid) + .bind(i as i64) + .execute(pool) + .await?; + } + Ok((paper, share)) +} + +fn shuffle(v: &mut Vec, mut seed: u64) { + if seed == 0 { + seed = 0x9e3779b97f4a7c15; + } + for i in (1..v.len()).rev() { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let j = (seed % (i as u64 + 1)) as usize; + v.swap(i, j); + } +} + +pub fn numerical_ok(selected: &str, answer: &str) -> bool { + let s: f64 = match selected.trim().parse() { + Ok(v) => v, + Err(_) => return false, + }; + let a: f64 = match answer.trim().parse() { + Ok(v) => v, + Err(_) => return false, + }; + let tol = a.abs() * 1e-3 + 1e-6; + (s - a).abs() <= tol +} + +pub fn is_correct(q: &Question, selected: &str) -> bool { + if selected.trim().is_empty() { + return false; + } + if q.qtype == "NUM" { + numerical_ok(selected, &q.answer) + } else { + selected.trim().eq_ignore_ascii_case(q.answer.trim()) + } +} + +pub async fn start_attempt(pool: &SqlitePool, uid: &str, paper: &str) -> anyhow::Result { + let existing: Option = + sqlx::query_scalar("SELECT a.id FROM attempts a WHERE a.paper_id=? AND a.user_id=? AND a.submitted_at IS NULL") + .bind(paper) + .bind(uid) + .fetch_optional(pool) + .await?; + if let Some(id) = existing { + return Ok(id); + } + let id = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO attempts(id,paper_id,user_id,started_at) VALUES(?,?,?,?)") + .bind(&id) + .bind(paper) + .bind(uid) + .bind(Utc::now().to_rfc3339()) + .execute(pool) + .await?; + let qids: Vec = sqlx::query_scalar("SELECT question_id FROM paper_questions WHERE paper_id=? ORDER BY ord") + .bind(paper) + .fetch_all(pool) + .await?; + for qid in &qids { + sqlx::query("INSERT INTO answers(attempt_id,question_id) VALUES(?,?)") + .bind(&id) + .bind(qid) + .execute(pool) + .await?; + } + Ok(id) +} + +#[derive(Deserialize)] +pub struct AnswerIn { + pub question_id: i64, + pub selected: String, + pub marked_review: Option, + pub time_secs: Option, +} + +#[derive(Deserialize)] +pub struct SaveIn { + pub answers: Vec, +} + +pub async fn save_answers(pool: &SqlitePool, attempt: &str, uid: &str, body: &SaveIn) -> anyhow::Result<()> { + let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?") + .bind(attempt) + .bind(uid) + .fetch_one(pool) + .await?; + if ok == 0 { + anyhow::bail!("Attempt not found"); + } + for a in &body.answers { + sqlx::query("UPDATE answers SET selected=?, marked_review=?, time_secs=? WHERE attempt_id=? AND question_id=?") + .bind(&a.selected) + .bind(a.marked_review.unwrap_or(false) as i64) + .bind(a.time_secs.unwrap_or(0).clamp(0, 7200)) + .bind(attempt) + .bind(a.question_id) + .execute(pool) + .await?; + } + Ok(()) +} + +pub async fn paper_questions(pool: &SqlitePool, paper: &str) -> anyhow::Result> { + let rows = sqlx::query( + "SELECT q.*, c.name AS chapter, s.name AS subtopic FROM questions q + JOIN chapters c ON c.id=q.chapter_id JOIN subtopics s ON s.id=q.subtopic_id + JOIN paper_questions pq ON pq.question_id=q.id WHERE pq.paper_id=? ORDER BY pq.ord", + ) + .bind(paper) + .fetch_all(pool) + .await?; + Ok(rows.iter().map(qrow).collect()) +} + +pub async fn answers_map(pool: &SqlitePool, attempt: &str) -> anyhow::Result> { + let rows = sqlx::query("SELECT question_id, selected, marked_review, time_secs FROM answers WHERE attempt_id=?") + .bind(attempt) + .fetch_all(pool) + .await?; + Ok(rows + .iter() + .map(|r| (r.get::("question_id"), (r.get::("selected"), r.get::("marked_review") != 0, r.get::("time_secs")))) + .collect()) +} + +pub async fn history_counts(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(usize, i32, i32)> { + let (rows, _, _, _) = grade(pool, attempt).await?; + let right = rows.iter().filter(|r| !r.selected.trim().is_empty() && r.correct).count(); + let wrong = rows.iter().filter(|r| !r.selected.trim().is_empty() && !r.correct).count(); + Ok((rows.len(), right as i32, wrong as i32)) +} + +#[derive(Clone)] +pub struct Graded { + pub q: Question, + pub selected: String, + pub correct: bool, + pub marks: i32, + pub time_secs: i64, +} + +pub async fn grade(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(Vec, i32, i32, i64)> { + let paper: String = sqlx::query_scalar("SELECT paper_id FROM attempts WHERE id=?").bind(attempt).fetch_one(pool).await?; + let qs = paper_questions(pool, &paper).await?; + let amap = answers_map(pool, attempt).await?; + let mut out = Vec::new(); + let (mut score, mut max, mut used) = (0i32, 0i32, 0i64); + for q in qs { + let (sel, _rev, secs) = amap.get(&q.id).cloned().unwrap_or_default(); + let attempted = !sel.trim().is_empty(); + let correct = is_correct(&q, &sel); + let marks = if !attempted { + 0 + } else if correct { + CORRECT + } else { + NEGATIVE + }; + score += marks; + max += CORRECT; + used += secs; + out.push(Graded { q, selected: sel, correct, marks, time_secs: secs }); + } + Ok((out, score, max, used)) +} + +pub async fn finalize(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(i32, i32, i64)> { + let (rows, score, max, used) = grade(pool, attempt).await?; + let uid: String = sqlx::query_scalar("SELECT user_id FROM attempts WHERE id=?").bind(attempt).fetch_one(pool).await?; + sqlx::query("UPDATE attempts SET submitted_at=?, score=?, max_score=?, used_secs=? WHERE id=?") + .bind(Utc::now().to_rfc3339()) + .bind(score) + .bind(max) + .bind(used) + .bind(attempt) + .execute(pool) + .await?; + for r in &rows { + if !r.selected.trim().is_empty() { + sqlx::query("INSERT OR IGNORE INTO seen_questions(user_id,question_id) VALUES(?,?)") + .bind(&uid) + .bind(r.q.id) + .execute(pool) + .await?; + } + } + Ok((score, max, used)) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..23288f2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,393 @@ +mod analytics; +mod auth; +mod db; +mod exam; +mod views; + +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{Html, IntoResponse, Redirect}; +use axum::routing::{get, post}; +use axum::{Form, Json, Router}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use std::str::FromStr; +use axum::routing::get_service; +use tower_http::services::ServeDir; + +use analytics::{analyse, coach_notes}; +use auth::Ctx; +use exam::{BuildForm, SaveIn}; + +type CtxArc = Arc; + +fn hx(body: String, code: StatusCode) -> impl IntoResponse { + (code, Html(body)) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init(); + let db_path = std::env::var("PYQ_DB").unwrap_or_else(|_| "pyq.db".into()); + let base = std::env::var("PYQ_BASE").unwrap_or_else(|_| String::new()); + let addr = std::env::var("PYQ_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".into()); + let opts = SqliteConnectOptions::from_str(&format!("sqlite://{db_path}?mode=rwc"))? + .create_if_missing(true) + .busy_timeout(std::time::Duration::from_secs(5)); + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect_with(opts) + .await?; + sqlx::query("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;").execute(&pool).await?; + db::init_db(&pool).await?; + let ctx = Arc::new(Ctx { pool, base: base.clone() }); + let static_dir = format!("{}/static", std::env::current_dir()?.display()); + + let app = Router::new() + .route("/", get(home)) + .route("/student", get(student_get).post(student_post)) + .route("/logout", get(auth::logout)) + .route("/terms", get(terms)) + .route("/privacy", get(privacy)) + .route("/build", get(build_page).post(build_post)) + .route("/attempt/{paper}", get(attempt_page).post(start_attempt)) + .route("/attempt/{attempt}/answer", post(save_answers)) + .route("/attempt/{attempt}/submit", post(submit)) + .route("/result/{attempt}", get(result_page)) + .route("/history", get(history_page)) + .route("/dashboard", get(dashboard_page)) + .route("/s/{token}", get(shared_paper)) + .route("/api/attempt/{attempt}/state", get(attempt_state)) + .route("/api/attempt/{attempt}/submit", post(submit_api)) + .nest_service("/static", get_service(ServeDir::new(static_dir))) + .with_state(ctx); + + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!("pyq clone listening on {addr} (base '{base}')"); + axum::serve(listener, app).await?; + Ok(()) +} + +async fn home(State(ctx): State) -> impl IntoResponse { + let c: (i64, i64, i64, i64, i64) = sqlx::query_as( + "SELECT (SELECT COUNT(*) FROM questions),(SELECT COUNT(DISTINCT subject||'-'||year||'-'||shift) FROM questions),(SELECT COUNT(*) FROM subtopics),(SELECT COUNT(DISTINCT subject) FROM questions),(SELECT COUNT(*) FROM users)", + ) + .fetch_one(&ctx.pool) + .await + .unwrap_or((0, 0, 0, 0, 0)); + hx(views::landing(&ctx, c.0, c.1, c.2, c.3, c.4, false), StatusCode::OK) +} + +async fn terms(State(ctx): State) -> impl IntoResponse { + hx(views::legal(&ctx, "Terms of use", TERMS), StatusCode::OK) +} + +async fn privacy(State(ctx): State) -> impl IntoResponse { + hx(views::legal(&ctx, "Privacy policy", PRIVACY), StatusCode::OK) +} + +async fn student_get(State(ctx): State, h: HeaderMap, q: Query>) -> axum::response::Response { + let next = q.get("next").cloned().unwrap_or_else(|| "/build".into()); + if auth::user_id(&ctx.pool, &h).await.is_some() { + return Redirect::to(&format!("{}{}", ctx.base, next)).into_response(); + } + hx(views::student(&ctx, &next, None), StatusCode::OK).into_response() +} + +async fn student_post(State(ctx): State, Form(f): Form) -> axum::response::Response { + auth::student_post(State(ctx), axum::Form(f)).await +} + +async fn build_page(State(ctx): State, h: HeaderMap) -> axum::response::Response { + let (_uid, name) = match ctx.require_user(&h).await { + Ok(v) => v, + Err(r) => return r, + }; + use sqlx::Row; + let rows = sqlx::query( + "SELECT c.id, c.subject, c.name, s.name AS sub, (SELECT COUNT(*) FROM questions q WHERE q.chapter_id=c.id) AS n + FROM chapters c JOIN subtopics s ON s.chapter_id=c.id ORDER BY c.subject, c.name, s.name", + ) + .fetch_all(&ctx.pool) + .await + .unwrap_or_default(); + let mut blocks: Vec = Vec::new(); + for r in &rows { + let subject: String = r.get("subject"); + let id: i64 = r.get("id"); + let cname: String = r.get("name"); + let sub: String = r.get("sub"); + let n: i64 = r.get("n"); + if !blocks.iter().any(|b| b.subject == subject) { + blocks.push(views::SubjectBlock { subject: subject.clone(), chapters: Vec::new() }); + } + let blk = blocks.iter_mut().find(|b| b.subject == subject).unwrap(); + match blk.chapters.iter_mut().find(|c| c.id == id) { + Some(c) => { + c.subtopics.push(sub); + c.count = n; + } + None => blk.chapters.push(views::ChapterBlock { id, name: cname, subtopics: vec![sub], count: n }), + } + } + let available: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM questions").fetch_one(&ctx.pool).await.unwrap_or(0); + let form = BuildForm { chapters: String::new(), count: Some(12), duration: Some(24), unseen_only: Some("on".into()) }; + hx(views::build(&ctx, &name, blocks, &form, available, None), StatusCode::OK).into_response() +} + +async fn build_post(State(ctx): State, h: HeaderMap, Form(f): Form) -> axum::response::Response { + let (uid, _name) = match ctx.require_user(&h).await { + Ok(v) => v, + Err(r) => return r, + }; + match exam::build_paper(&ctx.pool, &uid, &f).await { + Ok((paper, _share)) => Redirect::to(&format!("{}/attempt/{paper}", ctx.base)).into_response(), + Err(e) => { + use sqlx::Row; + let rows = sqlx::query( + "SELECT c.id, c.subject, c.name, s.name AS sub, (SELECT COUNT(*) FROM questions q WHERE q.chapter_id=c.id) AS n + FROM chapters c JOIN subtopics s ON s.chapter_id=c.id ORDER BY c.subject, c.name, s.name", + ) + .fetch_all(&ctx.pool) + .await + .unwrap_or_default(); + let mut blocks: Vec = Vec::new(); + for r in &rows { + let subject: String = r.get("subject"); + let id: i64 = r.get("id"); + let cname: String = r.get("name"); + let sub: String = r.get("sub"); + let n: i64 = r.get("n"); + if !blocks.iter().any(|b| b.subject == subject) { + blocks.push(views::SubjectBlock { subject: subject.clone(), chapters: Vec::new() }); + } + let blk = blocks.iter_mut().find(|b| b.subject == subject).unwrap(); + match blk.chapters.iter_mut().find(|c| c.id == id) { + Some(c) => { + c.subtopics.push(sub); + c.count = n; + } + None => blk.chapters.push(views::ChapterBlock { id, name: cname, subtopics: vec![sub], count: n }), + } + } + let available: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM questions").fetch_one(&ctx.pool).await.unwrap_or(0); + let msg = e.to_string(); + hx(views::build(&ctx, &_name, blocks, &f, available, Some(&msg)), StatusCode::OK).into_response() + } + } +} + +async fn start_attempt(State(ctx): State, h: HeaderMap, Path(paper): Path) -> axum::response::Response { + let (uid, _) = match ctx.require_user(&h).await { + Ok(v) => v, + Err(r) => return r, + }; + let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM papers WHERE id=? AND user_id=?") + .bind(&paper) + .bind(&uid) + .fetch_one(&ctx.pool) + .await + .unwrap_or(0); + if exists == 0 { + return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response(); + } + match exam::start_attempt(&ctx.pool, &uid, &paper).await { + Ok(a) => Redirect::to(&format!("{}/attempt/{paper}?run={a}", ctx.base)).into_response(), + Err(e) => { + let m = e.to_string(); + hx(views::error(&ctx, 500, &m), StatusCode::INTERNAL_SERVER_ERROR).into_response() + } + } +} + +async fn attempt_page(State(ctx): State, h: HeaderMap, Path(paper): Path, q: Query>) -> axum::response::Response { + let (uid, name) = match ctx.require_user(&h).await { + Ok(v) => v, + Err(r) => return r, + }; + let attempt_id = match q.get("run") { + Some(a) => a.clone(), + None => match exam::start_attempt(&ctx.pool, &uid, &paper).await { + Ok(a) => a, + Err(_) => return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response(), + }, + }; + let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=? AND paper_id=?") + .bind(&attempt_id) + .bind(&uid) + .bind(&paper) + .fetch_one(&ctx.pool) + .await + .unwrap_or(0); + if owned == 0 { + return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response(); + } + let questions = match exam::paper_questions(&ctx.pool, &paper).await { + Ok(v) => v, + Err(_) => return hx(views::error(&ctx, 500, ""), StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + let title: String = sqlx::query_scalar("SELECT title FROM papers WHERE id=?").bind(&paper).fetch_one(&ctx.pool).await.unwrap_or_else(|_| "Paper".into()); + let duration: i64 = sqlx::query_scalar("SELECT duration_min FROM papers WHERE id=?").bind(&paper).fetch_one(&ctx.pool).await.unwrap_or(30); + let amap = exam::answers_map(&ctx.pool, &attempt_id).await.unwrap_or_default(); + let answers: Vec<(i64, String, bool)> = questions + .iter() + .map(|q| { + let (sel, rev, _) = amap.get(&q.id).cloned().unwrap_or_default(); + (q.id, sel, rev) + }) + .collect(); + hx(views::attempt(&ctx, &name, &attempt_id, &questions, &answers, duration, &title), StatusCode::OK).into_response() +} + +async fn save_answers(State(ctx): State, h: HeaderMap, Path(attempt): Path, Json(body): Json) -> impl IntoResponse { + let uid = match auth::user_id(&ctx.pool, &h).await { + Some(u) => u, + None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED), + }; + match exam::save_answers(&ctx.pool, &attempt, &uid, &body).await { + Ok(_) => hx("{\"ok\":true}".into(), StatusCode::OK), + Err(e) => { + let m = e.to_string(); + hx(format!("{{\"error\":\"{m}\"}}"), StatusCode::NOT_FOUND) + } + } +} + +async fn attempt_state(State(ctx): State, h: HeaderMap, Path(attempt): Path) -> impl IntoResponse { + let uid = match auth::user_id(&ctx.pool, &h).await { + Some(u) => u, + None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED), + }; + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?") + .bind(&attempt) + .bind(&uid) + .fetch_one(&ctx.pool) + .await + .unwrap_or(0); + if n == 0 { + return hx("{\"error\":\"not found\"}".into(), StatusCode::NOT_FOUND); + } + let amap = exam::answers_map(&ctx.pool, &attempt).await.unwrap_or_default(); + let items: Vec = amap + .iter() + .map(|(qid, (sel, rev, secs))| serde_json::json!({"question_id": qid, "selected": sel, "marked_review": rev, "time_secs": secs})) + .collect(); + hx(serde_json::json!({"attempt": attempt, "answers": items}).to_string(), StatusCode::OK) +} + +async fn submit(State(ctx): State, h: HeaderMap, Path(attempt): Path) -> axum::response::Response { + let (uid, _) = match ctx.require_user(&h).await { + Ok(v) => v, + Err(r) => return r, + }; + let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?").bind(&attempt).bind(&uid).fetch_one(&ctx.pool).await.unwrap_or(0); + if owned == 0 { + return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response(); + } + match exam::finalize(&ctx.pool, &attempt).await { + Ok(_) => Redirect::to(&format!("{}/result/{attempt}", ctx.base)).into_response(), + Err(e) => { + let m = e.to_string(); + hx(views::error(&ctx, 500, &m), StatusCode::INTERNAL_SERVER_ERROR).into_response() + } + } +} + +async fn submit_api(State(ctx): State, h: HeaderMap, Path(attempt): Path) -> impl IntoResponse { + let uid = match auth::user_id(&ctx.pool, &h).await { + Some(u) => u, + None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED), + }; + let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?").bind(&attempt).bind(&uid).fetch_one(&ctx.pool).await.unwrap_or(0); + if owned == 0 { + return hx("{\"error\":\"not found\"}".into(), StatusCode::NOT_FOUND); + } + match exam::finalize(&ctx.pool, &attempt).await { + Ok((score, max, used)) => hx(serde_json::json!({"score": score, "max": max, "used_secs": used, "result_url": format!("{}/result/{attempt}", ctx.base)}).to_string(), StatusCode::OK), + Err(e) => { + let m = e.to_string(); + hx(format!("{{\"error\":\"{m}\"}}"), StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +async fn result_page(State(ctx): State, h: HeaderMap, Path(attempt): Path) -> axum::response::Response { + let (uid, name) = match ctx.require_user(&h).await { + Ok(v) => v, + Err(r) => return r, + }; + let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=? AND submitted_at IS NOT NULL").bind(&attempt).bind(&uid).fetch_one(&ctx.pool).await.unwrap_or(0); + if owned == 0 { + return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response(); + } + let (rows, score, max, used) = match exam::grade(&ctx.pool, &attempt).await { + Ok(v) => v, + Err(e) => { + let m = e.to_string(); + return hx(views::error(&ctx, 500, &m), StatusCode::INTERNAL_SERVER_ERROR).into_response(); + } + }; + let a = analyse(&rows, score, max, used); + let rv: Vec = rows + .iter() + .map(|r| views::RowView { q: &r.q, selected: &r.selected, marks: r.marks, correct: r.correct, time_secs: r.time_secs }) + .collect(); + hx(views::result(&ctx, &name, &attempt, &a, &rv), StatusCode::OK).into_response() +} + +async fn history_page(State(ctx): State, h: HeaderMap) -> axum::response::Response { + let (uid, name) = match ctx.require_user(&h).await { + Ok(v) => v, + Err(r) => return r, + }; + let tests = analytics::history(&ctx.pool, &uid).await.unwrap_or_default(); + hx(views::history(&ctx, &name, &tests), StatusCode::OK).into_response() +} + +async fn dashboard_page(State(ctx): State, h: HeaderMap) -> axum::response::Response { + let (uid, name) = match ctx.require_user(&h).await { + Ok(v) => v, + Err(r) => return r, + }; + let tests = analytics::history(&ctx.pool, &uid).await.unwrap_or_default(); + let weak = analytics::weakest(&ctx.pool, &uid).await.unwrap_or_default(); + let marks: i32 = tests.iter().map(|t| t.score).sum(); + let gained: i32 = tests.iter().map(|t| t.right).sum(); + let lost: i32 = tests.iter().map(|t| t.wrong).sum(); + let notes = coach_notes( + &analytics::Analysis { + score: tests.first().map(|t| t.score).unwrap_or(0), + max_score: tests.first().map(|t| t.max_score).unwrap_or(0), + used_secs: tests.first().map(|t| t.used_secs).unwrap_or(0), + right: tests.first().map(|t| t.right).unwrap_or(0), + wrong: tests.first().map(|t| t.wrong).unwrap_or(0), + skipped: 0, + by_subject: vec![], + by_difficulty: vec![], + by_topic: vec![], + mistakes: vec![], + }, + &tests, + ); + hx(views::dashboard(&ctx, &name, &tests, &weak, ¬es, (tests.len() as i32, marks, gained, lost)), StatusCode::OK).into_response() +} + +async fn shared_paper(State(ctx): State, Path(token): Path) -> axum::response::Response { + let row = sqlx::query("SELECT id, title, duration_min FROM papers WHERE share_token=?").bind(&token).fetch_optional(&ctx.pool).await; + let (id, title, duration) = match row { + Ok(Some(r)) => { + use sqlx::Row; + (r.get::("id"), r.get::("title"), r.get::("duration_min")) + } + _ => return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response(), + }; + let questions = match exam::paper_questions(&ctx.pool, &id).await { + Ok(v) => v, + Err(_) => return hx(views::error(&ctx, 500, ""), StatusCode::INTERNAL_SERVER_ERROR).into_response(), + }; + hx(views::shared(&ctx, &title, duration, &questions), StatusCode::OK).into_response() +} + +const TERMS: &str = include_str!("../content/terms.html"); +const PRIVACY: &str = include_str!("../content/privacy.html"); \ No newline at end of file diff --git a/src/views.rs b/src/views.rs new file mode 100644 index 0000000..824dc11 --- /dev/null +++ b/src/views.rs @@ -0,0 +1,563 @@ +use crate::analytics::{accuracy, fmt_dur, fmt_pct, Analysis, Bucket, TestRow}; +use crate::auth::Ctx; +use crate::exam::{BuildForm, Question}; + +pub fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len() + 16); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\'' => o.push_str("'"), + _ => o.push(c), + } + } + o +} + +pub struct ChapterBlock { + pub id: i64, + pub name: String, + pub subtopics: Vec, + pub count: i64, +} + +pub struct SubjectBlock { + pub subject: String, + pub chapters: Vec, +} + +fn head(ctx: &Ctx, title: &str) -> String { + format!( + r#" + +{t} · PYQ + + +"#, + t = esc(title), + b = ctx.base + ) +} + +fn nav(ctx: &Ctx, user: Option<&str>) -> String { + let brand = format!( + r#"
PYQjee main practice
") +} + +fn foot(ctx: &Ctx) -> String { + format!( + r#"
PYQ clone · running on sqlite · built as a working copy of zenithprepare.com/PYQterms · privacy
"#, + b = ctx.base + ) +} + +pub fn shell(ctx: &Ctx, user: Option<&str>, title: &str, body: String) -> String { + format!("{}{}{}{}", head(ctx, title), nav(ctx, user), body, foot(ctx)) +} + +pub fn landing(ctx: &Ctx, q: i64, shifts: i64, subtopics: i64, subjects: i64, students: i64, signed: bool) -> String { + let cta = if signed { + format!(r#"build a paper"#, b = ctx.base) + } else { + format!(r#"start a paper"#, b = ctx.base) + }; + let body = format!( + r#"

JEE Main practice, built like the real exam screen.

+

Pick the chapters you just revised. Take the paper on a timed computer-based screen with the +4 / −1 marking scheme. Get the analysis the moment you submit.

+
+
questions{q}
+
shifts{shifts}
+
subtopics{subtopics}
+
students{students}
+
+
+

Build

Choose any mix of chapters across {subjects} subjects. Questions are drawn from real shift data and, with unseen-only on, you never repeat one by accident.

+

Attempt

Subject tabs, a colour-coded question palette, mark for review, numerical answers, a countdown timer, and +4 for a correct answer with −1 for a wrong one.

+

Analysis

Score, accuracy, time per question, and results broken down by subject, chapter and difficulty. Solutions sit under every question.

+

Progress

Every submitted paper is kept. The dashboard tracks percentage and accuracy across tests and names the subtopics costing you the most marks.

+
+

{cta}

"#, + q = q, + shifts = shifts, + subtopics = subtopics, + subjects = subjects, + students = students, + cta = cta + ); + shell(ctx, None, "Home", body) +} + +pub fn student(ctx: &Ctx, next: &str, err: Option<&str>) -> String { + let e = err.map(|m| format!(r#"

{m}

"#)).unwrap_or_default(); + let body = format!( + r#"

Sign in

+

This clone runs a local sign-in so you can use the whole flow without a Google account. Enter any name and email; your papers stay in this database.

+
+ + + + {e} +
+
"#, + b = ctx.base, + next = esc(next), + e = e + ); + shell(ctx, None, "Sign in", body) +} + +pub fn legal(ctx: &Ctx, title: &str, body_md: &str) -> String { + let body = format!(r#"

{t}

{b}
"#, t = esc(title), b = body_md); + shell(ctx, None, title, body) +} + +pub fn error(ctx: &Ctx, code: u16, detail: &str) -> String { + let (t, d) = match code { + 404 => ("Nothing at this address".to_string(), "The link may be wrong, or the paper was never created.".to_string()), + _ => ("Something broke on our side".to_string(), detail.to_string()), + }; + let body = format!( + r#"

{t}

{d}

back to home

"#, + t = esc(&t), + d = esc(&d), + b = ctx.base + ); + shell(ctx, None, &t, body) +} + +pub fn build(ctx: &Ctx, user: &str, blocks: Vec, _form: &BuildForm, available: i64, err: Option<&str>) -> String { + let mut picker = String::new(); + for sb in &blocks { + picker.push_str(&format!("

{}

", esc(&sb.subject))); + for ch in &sb.chapters { + let subs = ch.subtopics.iter().map(|s| esc(s)).collect::>().join(", "); + picker.push_str(&format!( + r#""#, + cid = ch.id, + cname = esc(&ch.name), + subs = subs, + count = ch.count + )); + } + } + let e = err.map(|m| format!(r#"

{m}

"#)).unwrap_or_default(); + let body = format!( + r#"

Build a paper

+

{available} questions match this bank. Pick the chapters you are revising; the paper is drawn from them and marked on the +4 / −1 scheme.

+
+ +
{picker}
+
+
+

paper settings

+ + + + + +

no chapters picked yet

+ {e} +
+
+
+
+"#, + available = available, + b = ctx.base, + picker = picker, + e = e + ); + shell(ctx, Some(user), "Build", body) +} + +fn palette(questions: &[Question], answers: &[(i64, String, bool)]) -> String { + questions + .iter() + .enumerate() + .map(|(i, q)| { + let a = answers.iter().find(|(id, _, _)| *id == q.id); + let sel = a.map(|(_, s, _)| !s.trim().is_empty()).unwrap_or(false); + let rev = a.map(|(_, _, r)| *r).unwrap_or(false); + let cls = match (sel, rev) { + (true, true) => "pal rev", + (true, false) => "pal ans", + _ => "pal", + }; + format!(r#""#, cls = cls, n = i + 1) + }) + .collect::>() + .join("") +} + +pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], answers: &[(i64, String, bool)], duration_min: i64, title: &str) -> String { + let body_questions: Vec = questions + .iter() + .enumerate() + .map(|(i, q)| { + let cur = answers.iter().find(|(id, _, _)| *id == q.id); + let sel = cur.map(|(_, s, _)| s.clone()).unwrap_or_default(); + let rev = cur.map(|(_, _, r)| *r).unwrap_or(false); + let opts: Vec = if q.qtype == "NUM" { + vec![format!( + r#""#, + id = q.id, + v = esc(&sel) + )] + } else { + ["A", "B", "C", "D"] + .iter() + .enumerate() + .map(|(k, letter)| { + let text = q.opts[k].clone(); + let checked = if sel.trim().eq_ignore_ascii_case(letter) { " checked" } else { "" }; + format!( + r#""#, + cls = if sel.trim().eq_ignore_ascii_case(letter) { " sel" } else { "" }, + id = q.id, + letter = letter, + checked = checked, + t = esc(&text) + ) + }) + .collect() + }; + format!( + r#"
+
+ question {n} · {subj} · {ch} + {qt} · +4 / −1{revflag} +
+

{stem}

+
{sub2} · {year} {shift} · {difficulty}
+ {opts} +
+ + + +
+
"#, + hidden = if i == 0 { "" } else { " hidden" }, + n = i + 1, + subj = esc(&q.subject), + ch = esc(&q.chapter), + qt = esc(&q.qtype), + revflag = if rev { " · marked" } else { "" }, + stem = esc(&q.stem), + sub2 = esc(&q.subtopic), + year = q.year, + shift = esc(&q.shift), + difficulty = esc(&q.difficulty), + opts = opts.join("") + ) + }) + .collect(); + let body = format!( + r#"
+

{title}

{n} questions · +4 / −1
+
{dur}
remaining
+
+
+
+
{qs}
+

palette

{pal}
+

answered · review · blank

+ +
+
+"#, + title = esc(title), + n = questions.len(), + dur = fmt_dur(duration_min as i64 * 60), + secs = duration_min * 60, + b = ctx.base, + a = esc(attempt_id), + qs = body_questions.join(""), + pal = palette(questions, answers) + ); + shell(ctx, Some(user), "Test", body) +} + +pub struct RowView<'a> { + pub q: &'a Question, + pub selected: &'a str, + pub marks: i32, + pub correct: bool, + pub time_secs: i64, +} + +fn seg(b: &Bucket) -> String { + let t = b.total().max(1) as f64; + format!( + r#"
"#, + b.right as f64 / t * 100.0, + b.wrong as f64 / t * 100.0, + b.skipped as f64 / t * 100.0 + ) +} + +fn bucket_rows(items: &[Bucket]) -> String { + items + .iter() + .map(|b| { + format!( + r#"{l}{r}{w}{s}{seg}{p}%{st}"#, + l = esc(&b.label), + r = b.right, + w = b.wrong, + s = b.skipped, + seg = seg(b), + p = b.pct(), + st = b.status() + ) + }) + .collect() +} + +pub fn result(ctx: &Ctx, user: &str, _attempt_id: &str, a: &Analysis, rows: &[RowView]) -> String { + let detail: String = rows + .iter() + .enumerate() + .map(|(i, r)| { + let state = if r.correct { "correct" } else if r.selected.trim().is_empty() { "blank" } else { "wrong" }; + let cls = match state { "correct" => "ok-t", "wrong" => "bad-t", _ => "dim" }; + let opts: String = if r.q.qtype == "NUM" { + format!(r#"

your answer: {sel} · correct: {ans}

"#, sel = esc(if r.selected.is_empty() { "not attempted" } else { r.selected }), ans = esc(&r.q.answer)) + } else { + ["A", "B", "C", "D"] + .iter() + .enumerate() + .map(|(k, l)| { + let is_ans = r.q.answer.trim().eq_ignore_ascii_case(l); + let is_sel = r.selected.trim().eq_ignore_ascii_case(l); + let style = if is_ans { "ok-t" } else if is_sel { "bad-t" } else { "dim" }; + format!(r#"{l}: {t}{mark}"#, style = style, l = l, t = esc(&r.q.opts[k]), mark = if is_ans { " (correct)" } else if is_sel { " (you)" } else { "" }) + }) + .collect() + }; + format!( + r#"
q{n} · {subj} · {state} · {marks:+} marks +

{stem}

+

{opts}

+

{sub2} · {ch} · {year} {shift} · {difficulty} · {qtime}

+

{sol}

"#, + n = i + 1, + subj = esc(&r.q.subject), + cls = cls, + state = state, + marks = r.marks, + stem = esc(&r.q.stem), + opts = opts, + sub2 = esc(&r.q.subtopic), + ch = esc(&r.q.chapter), + year = r.q.year, + shift = esc(&r.q.shift), + difficulty = esc(&r.q.difficulty), + qtime = if r.time_secs > 0 { format!("{}s spent", r.time_secs) } else { "no time recorded".to_string() }, + sol = esc(&r.q.solution) + ) + }) + .collect(); + let topic_buckets: Vec = a.by_topic.iter().map(|(b, _)| b.to_owned()).collect(); + let body = format!( + r#"

Result

+
+
score{score} / {max}
+
percentage{pct}%
+
accuracy{acc}%
+
time used{dur}
+
+
+

by subject

{subj}
subjectrightwrongblank%
+

by difficulty

{diff}
difficultyrightwrongblank%
+
+

chapters that cost you marks

+
{topic}
subtopicrightwrongblank%
+

every question

+{detail} +

+ all papers + dashboard + new paper +

"#, + score = a.score, + max = a.max_score, + pct = fmt_pct(a.score, a.max_score), + acc = accuracy(a.right, a.wrong), + dur = fmt_dur(a.used_secs), + subj = bucket_rows(&a.by_subject), + diff = bucket_rows(&a.by_difficulty), + topic = bucket_rows(&topic_buckets), + detail = detail, + b = ctx.base + ); + shell(ctx, Some(user), "Result", body) +} + +pub fn history(ctx: &Ctx, user: &str, tests: &[TestRow]) -> String { + let rows: String = if tests.is_empty() { + String::new() + } else { + tests + .iter() + .map(|t| { + format!( + r#"{d}{title}{score} / {max}{p}%{acc}%{dur}open"#, + d = t.started_at.format("%d %b %Y"), + title = esc(&t.title), + score = t.score, + max = t.max_score, + p = fmt_pct(t.score, t.max_score), + acc = accuracy(t.right, t.wrong), + dur = fmt_dur(t.used_secs), + b = ctx.base, + id = esc(&t.attempt_id) + ) + }) + .collect() + }; + let body = if tests.is_empty() { + format!( + r#"

History

No papers yet

Nothing is here because no paper has been submitted. Build one and it appears here with its score and accuracy.

build your first paper

"#, + b = ctx.base + ) + } else { + format!( + r#"

History

{rows}
datepaperscore%accuracytime
"#, + rows = rows + ) + }; + shell(ctx, Some(user), "History", body) +} + +pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, String, String)], notes: &[String], totals: (i32, i32, i32, i32)) -> String { + let (taken, marks, gained, lost) = totals; + let trend: String = if tests.is_empty() { + String::new() + } else { + let best = tests.iter().map(|t| t.score * 100 / t.max_score.max(1)).max().unwrap_or(0); + let mut s = String::from(r#"

percentage across papers

"#); + for t in tests { + s.push_str(&format!( + r#""#, + t.started_at.format("%d %b"), + esc(&t.title), + fmt_pct(t.score, t.max_score), + accuracy(t.right, t.wrong) + )); + } + s.push_str("
datepaper%accuracy
{}{}{}%{}%
"); + s = s.replace("{best}", &best.to_string()); + s + }; + let weak_rows: String = weak + .iter() + .map(|(b, chapter, subtopic)| { + format!( + r#"{sub} · {ch}{t}{w}{s}{seg}{st}"#, + sub = esc(subtopic), + ch = esc(chapter), + t = b.total(), + w = b.wrong, + s = b.skipped, + seg = seg(b), + st = b.status() + ) + }) + .collect(); + let notes_html: String = notes.iter().map(|n| format!("
  • {}
  • ", esc(n))).collect(); + let body = if tests.is_empty() { + format!( + r#"

    Dashboard

    No test data yet

    The trend, the weak-subtopic table and the coach notes all come from papers you submit, so there is nothing to show until the first one is done.

    build a paper

    "#, + b = ctx.base + ) + } else { + format!( + r#"

    Dashboard

    +
    +
    papers submitted{taken}
    +
    marks scored{marks}
    +
    correct{gained}
    +
    wrong{lost}
    +
    +{trend} +

    weakest subtopics across all papers

    +
    {weak}
    subtopicseenwrongblank
    +

    coach notes

    +
      {notes}

    Written from your own submitted papers. No data leaves this machine.

    +

    all papers

    "#, + taken = taken, + marks = marks, + gained = gained, + lost = lost, + trend = trend, + weak = weak_rows, + notes = notes_html, + b = ctx.base + ) + }; + shell(ctx, Some(user), "Dashboard", body) +} + +pub fn shared(ctx: &Ctx, title: &str, duration: i64, questions: &[Question]) -> String { + let qs: String = questions + .iter() + .enumerate() + .map(|(i, q)| { + let opts = if q.qtype == "NUM" { + String::new() + } else { + ["A", "B", "C", "D"] + .iter() + .enumerate() + .map(|(k, l)| format!("

    {l} {}

    ", esc(&q.opts[k]))) + .collect() + }; + format!( + r#"
    q{n} · {subj} · {sub2}

    {stem}

    {opts}
    "#, + n = i + 1, + subj = esc(&q.subject), + sub2 = esc(&q.subtopic), + stem = esc(&q.stem), + opts = opts + ) + }) + .collect(); + let body = format!( + r#"

    {title}

    {count} questions · {dur} minutes · no answers or scores on this page.

    {qs}
    "#, + title = esc(title), + count = questions.len(), + dur = duration, + qs = qs + ); + shell(ctx, None, title, body) +} \ No newline at end of file diff --git a/static/exam.js b/static/exam.js new file mode 100644 index 0000000..2031d93 --- /dev/null +++ b/static/exam.js @@ -0,0 +1,133 @@ +// Exam screen: pane navigation, palette sync, per-question timing and autosave. +(() => { + const panes = [...document.querySelectorAll('.qpane')]; + const palBtns = [...document.querySelectorAll('#pal .pal')]; + const timerEl = document.getElementById('timer'); + let cur = 0; + + const entered = new Map(); + for (const p of panes) { + const box = p.querySelector('input'); + if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0)); + } + + function show(i) { + cur = Math.max(0, Math.min(panes.length - 1, i)); + panes.forEach((p, k) => { p.hidden = k !== cur; }); + palBtns.forEach((b, k) => b.classList.toggle('cur', k === cur)); + const focusable = panes[cur].querySelector('input:not([type=hidden])'); + if (focusable && matchMedia('(pointer: fine)').matches) focusable.focus({ preventScroll: true }); + history.replaceState(null, '', '#q' + (cur + 1)); + } + + function questionId(pane) { + const radios = [...pane.querySelectorAll('input[type=radio]')]; + if (radios.length) return Number(radios[0].name.replace('q-', '')); + return Number(pane.querySelector('input[type=text]').name.replace('num-', '')); + } + + function collect(pane) { + const i = pane.dataset.i; + const radios = [...pane.querySelectorAll('input[type=radio]')]; + const picked = radios.find(r => r.checked); + const num = pane.querySelector('input[type=text]'); + return { + question_id: questionId(pane), + selected: picked ? picked.value : (num ? num.value.trim() : ''), + marked_review: pane.dataset.review === '1', + time_secs: Number(entered.get(i) || 0), + }; + } + + function syncPalette(pane) { + const btn = palBtns[Number(pane.dataset.i)]; + if (!btn) return; + const answered = collect(pane).selected !== ''; + const review = pane.dataset.review === '1'; + btn.classList.toggle('ans', answered && !review); + btn.classList.toggle('rev', answered && review); + btn.classList.toggle('skip', !answered); + btn.classList.remove('cur'); + } + + let saving = false; + async function save(all) { + if (saving) return; + saving = true; + const panesToSave = all ? panes : [panes[cur]]; + const attemptId = document.getElementById('exam-form').dataset.attempt; + try { + await fetch(`/attempt/${attemptId}/answer`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ answers: panesToSave.map(collect) }), + }); + } catch (_) { + // Server unreachable: the selection stays on screen and travels with the form submit. + } + saving = false; + } + + for (const pane of panes) { + pane.addEventListener('change', () => { + pane.querySelectorAll('.opt').forEach(o => { + const input = o.querySelector('input'); + o.classList.toggle('sel', !!input && input.checked); + }); + syncPalette(pane); + save(false); + }); + pane.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-act]'); + if (!btn) return; + const act = btn.dataset.act; + if (act === 'save') { save(false).then(() => show(cur + 1)); } + if (act === 'review') { + pane.dataset.review = pane.dataset.review === '1' ? '0' : '1'; + btn.classList.toggle('danger', pane.dataset.review === '1'); + syncPalette(pane); + save(false); + } + if (act === 'clear') { + pane.querySelectorAll('input[type=radio]').forEach(r => { r.checked = false; }); + const num = pane.querySelector('input[type=text]'); + if (num) num.value = ''; + pane.querySelectorAll('.opt').forEach(o => o.classList.remove('sel')); + syncPalette(pane); + save(false); + } + }); + } + + palBtns.forEach((b, i) => b.addEventListener('click', () => show(i))); + + document.addEventListener('keydown', e => { + if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; + if (e.key === 'ArrowRight') show(cur + 1); + if (e.key === 'ArrowLeft') show(cur - 1); + }); + + let left = Number(timerEl.dataset.secs || 0); + if (left > 0) { + setInterval(() => { + left -= 1; + if (left <= 0) { + clearInterval(this); + document.getElementById('exam-form').requestSubmit(); + return; + } + const h = String(Math.floor(left / 3600)).padStart(2, '0'); + const m = String(Math.floor(left / 60) % 60).padStart(2, '0'); + const s = String(left % 60).padStart(2, '0'); + timerEl.textContent = `${h}:${m}:${s}`; + }, 1000); + } + + document.getElementById('exam-form').addEventListener('submit', e => { + if (!confirm('Submit this paper? You cannot change answers afterwards.')) e.preventDefault(); + }); + + const hash = Number((location.hash.match(/q(\d+)/) || [])[1]); + show(hash ? hash - 1 : 0); + if (cur === 0 && !hash) panes.forEach(syncPalette); +})(); \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..eddbfbe --- /dev/null +++ b/static/style.css @@ -0,0 +1,64 @@ +*{box-sizing:border-box;margin:0;padding:0;border-radius:0!important} +:root{--bg:#0b0b0b;--panel:#111111;--card:#141414;--input:#0e0e0e;--border:#2e2e2e;--border2:#3d3d3d;--edge:#626262;--txt:#e5e5e5;--body:#cccccc;--muted:#9a9a9a;--dim:#7d7d7d;--ok:#10b981;--cyan:#38bdf8;--amber:#f59e0b;--bad:#ff5555;--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +html{background:var(--bg)} +body{background:var(--bg);color:var(--body);font-family:var(--mono);font-size:14px;line-height:1.55;max-width:1180px;margin:0 auto;padding:0 16px 64px} +a{color:var(--cyan);text-decoration:none} +a:hover{text-decoration:underline} +h1{font-size:2.1rem;font-weight:600;letter-spacing:-.04em;color:#fff;line-height:1.15;margin:18px 0 10px} +h2{font-size:.76rem;font-weight:500;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin:28px 0 12px;border-bottom:1px solid var(--border);padding-bottom:8px} +h3{color:#fff;font-size:1rem;margin:0 0 6px} +.topbar{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);padding:12px 0;position:sticky;top:0;background:rgba(11,11,11,.92);z-index:5} +.brand{color:#fff;font-weight:600;font-size:1rem;letter-spacing:-.02em} +.brand span{color:var(--dim);font-weight:400;font-size:.78rem;margin-left:10px} +.nav{display:flex;gap:14px;align-items:center;font-size:.8rem} +.btn{display:inline-flex;align-items:center;justify-content:center;padding:8px 18px;background:#161616;color:#ddd;border:1px solid var(--edge);font-size:.8rem;font-family:inherit;cursor:pointer;letter-spacing:.02em;transition:all .15s ease;text-decoration:none} +.btn:hover:not(:disabled){background:#242424;border-color:#555;color:#fff;text-decoration:none} +.btn:active:not(:disabled){background:#fff;color:#000;border-color:#fff} +.btn:disabled{opacity:.3;cursor:not-allowed} +.btn.primary{background:var(--cyan);border-color:var(--cyan);color:#001018;font-weight:600} +.btn.primary:hover{background:#7cd4fc;color:#000} +.btn.ok{border-color:var(--ok);color:var(--ok)} +.btn.danger{border-color:var(--bad);color:var(--bad)} +.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px} +.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:14px} +.card{background:var(--card);border:1px solid var(--border);padding:16px} +.card:hover{border-color:var(--border2)} +.meta{color:var(--muted);font-size:.78rem} +.dim{color:var(--dim);font-size:.78rem} +table{width:100%;border-collapse:collapse;font-size:.82rem} +th{color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-size:.68rem;text-align:left;padding:8px;border-bottom:1px solid var(--border)} +td{padding:8px;border-bottom:1px solid #1c1c1c;font-variant-numeric:tabular-nums} +input[type=text],input[type=email],input[type=number],select,textarea{background:var(--input);color:#e5e5e5;border:1px solid var(--edge);padding:9px 12px;font-family:inherit;font-size:.82rem;outline:none;width:100%} +input:focus,textarea:focus,select:focus{border-color:var(--cyan)} +input::placeholder{color:var(--dim)} +label{display:block;font-size:.75rem;color:var(--muted);margin:10px 0 4px;text-transform:uppercase;letter-spacing:.08em} +.chk{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--edge);padding:10px 12px;margin:6px 0;background:var(--panel);cursor:pointer;font-size:.85rem} +.chk:hover{border-color:var(--border2)} +.chk input{width:auto;margin-top:3px;accent-color:var(--cyan)} +.palette{display:grid;grid-template-columns:repeat(5,1fr);gap:6px} +.pal{aspect-ratio:1;display:grid;place-items:center;border:1px solid var(--edge);background:var(--panel);color:var(--muted);font-size:.75rem;cursor:pointer;min-height:44px} +.pal.cur{border-color:var(--cyan);color:#fff} +.pal.ans{background:#12301f;border-color:var(--ok);color:var(--ok)} +.pal.rev{background:#2a1f05;border-color:var(--amber);color:var(--amber)} +.pal.skip{background:var(--panel)} +.opt{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--edge);padding:10px 12px;margin:8px 0;background:var(--panel);cursor:pointer} +.opt:hover{border-color:var(--border2)} +.opt.sel{border-color:var(--cyan);background:#0d1b24} +.status-dot{width:6px;height:6px;background:#444;display:inline-block;flex:none} +.ok-t{color:var(--ok)}.bad-t{color:var(--bad)}.warn-t{color:var(--amber)}.cyan-t{color:var(--cyan)} +.bar{height:2px;background:#1c1c1c;overflow:hidden;margin-top:8px} +.bar i{display:block;height:100%;background:var(--ok)} +.seg{display:flex;height:10px;background:#1c1c1c;overflow:hidden} +.seg i{display:block;height:100%} +:focus-visible{outline:2px solid var(--cyan);outline-offset:2px} +.cbt{display:grid;grid-template-columns:1fr 220px;gap:14px} +.timer{font-variant-numeric:tabular-nums;color:var(--amber);font-weight:600} +.kv{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:14px 0} +.kv .card b{display:block;font-size:1.5rem;color:#fff;font-variant-numeric:tabular-nums} +footer{margin-top:48px;border-top:1px solid var(--border);padding-top:16px;color:var(--dim);font-size:.75rem;display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px} +/* Tablet: the CBT palette drops under the question instead of squeezing it. */ +@media(max-width:1024px){.cbt{grid-template-columns:1fr}.palette{grid-template-columns:repeat(8,1fr)}} +/* Phone: one column throughout, a 2x2 metric grid and full-width palette taps. */ +@media(max-width:700px){.grid2,.grid3{grid-template-columns:1fr}.kv{grid-template-columns:1fr 1fr}h1{font-size:1.6rem}.nav a.hidem{display:none}.palette{grid-template-columns:repeat(5,1fr)}.cbt{gap:10px}.topbar{padding:10px 0}.brand span{display:none}} +@media(max-width:420px){.kv{grid-template-columns:1fr}.palette{grid-template-columns:repeat(4,1fr)}.brand{font-size:.9rem}.btn{padding:10px 14px;min-height:44px}} +@media(hover:none){.pal{min-height:48px}} diff --git a/tests/form_decode.rs b/tests/form_decode.rs new file mode 100644 index 0000000..8728dc7 --- /dev/null +++ b/tests/form_decode.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +struct Repeated { + chapters: Vec, +} + +#[test] +fn repeated_keys_do_not_decode_into_vec() { + // Documents the behaviour that forced comma-separated ids on the build form. + let out = serde_urlencoded::from_str::("chapters=1&chapters=4"); + assert!(out.is_err(), "serde_urlencoded keeps the last repeated key only"); +} + +#[test] +fn comma_separated_ids_decode() { + #[derive(Deserialize, Debug)] + struct Cs { + chapters: String, + } + let v = serde_urlencoded::from_str::("chapters=1,4,7").expect("decodes"); + assert_eq!(v.chapters, "1,4,7"); +} \ No newline at end of file diff --git a/tests/grading.rs b/tests/grading.rs new file mode 100644 index 0000000..dc77052 --- /dev/null +++ b/tests/grading.rs @@ -0,0 +1,95 @@ +// Grading rules: MCQ comparison, numerical tolerance, and the +4 / −1 scheme. +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq)] +struct Q { + qtype: &'static str, + answer: String, +} + +fn numerical_ok(selected: &str, answer: &str) -> bool { + let s: f64 = match selected.trim().parse() { + Ok(v) => v, + Err(_) => return false, + }; + let a: f64 = match answer.trim().parse() { + Ok(v) => v, + Err(_) => return false, + }; + let tol = a.abs() * 1e-3 + 1e-6; + (s - a).abs() <= tol +} + +fn is_correct(q: &Q, selected: &str) -> bool { + if selected.trim().is_empty() { + return false; + } + if q.qtype == "NUM" { + numerical_ok(selected, &q.answer) + } else { + selected.trim().eq_ignore_ascii_case(q.answer.trim()) + } +} + +fn marks(q: &Q, selected: &str) -> i32 { + if selected.trim().is_empty() { + 0 + } else if is_correct(q, selected) { + 4 + } else { + -1 + } +} + +#[test] +fn mcq_is_case_insensitive_and_trimmed() { + let q = Q { qtype: "MCQ", answer: "A".into() }; + assert!(is_correct(&q, "a")); + assert!(is_correct(&q, " A ")); + assert!(!is_correct(&q, "B")); +} + +#[test] +fn blank_answer_is_wrong_not_skipped() { + let q = Q { qtype: "MCQ", answer: "A".into() }; + assert_eq!(marks(&q, ""), 0, "a blank question scores zero, not minus one"); + assert_eq!(marks(&q, " "), 0); +} + +#[test] +fn wrong_answer_costs_one_mark() { + let q = Q { qtype: "MCQ", answer: "A".into() }; + assert_eq!(marks(&q, "C"), -1); +} + +#[test] +fn correct_answer_scores_four() { + let q = Q { qtype: "MCQ", answer: "A".into() }; + assert_eq!(marks(&q, "A"), 4); +} + +#[test] +fn numerical_answers_allow_small_float_error() { + let q = Q { qtype: "NUM", answer: "20".into() }; + assert!(is_correct(&q, "20.0")); + assert!(is_correct(&q, "20.01"), "within 0.1 percent tolerance"); + assert!(!is_correct(&q, "20.5"), "outside tolerance"); + assert!(!is_correct(&q, "abc"), "non numeric input is wrong"); +} + +#[test] +fn zero_answer_is_not_mistaken_for_blank() { + let q = Q { qtype: "NUM", answer: "0".into() }; + assert!(is_correct(&q, "0")); + assert_eq!(marks(&q, "0"), 4); +} + +#[test] +fn mixed_paper_score_matches_hand_calculation() { + let mut answers = HashMap::new(); + answers.insert(1i64, ("A".to_string(), 4)); // correct + answers.insert(2i64, ("B".to_string(), -1)); // wrong + answers.insert(3i64, (String::new(), 0)); // blank + let total: i32 = answers.values().map(|(_, m)| m).sum(); + assert_eq!(total, 3); +} \ No newline at end of file