diff --git a/src/hooks/use-sqlite.js b/2017/src/hooks/use-sqlite.js
similarity index 100%
rename from src/hooks/use-sqlite.js
rename to 2017/src/hooks/use-sqlite.js
diff --git a/src/index.css b/2017/src/index.css
similarity index 100%
rename from src/index.css
rename to 2017/src/index.css
diff --git a/src/lib/admission-blocks.js b/2017/src/lib/admission-blocks.js
similarity index 87%
rename from src/lib/admission-blocks.js
rename to 2017/src/lib/admission-blocks.js
index 15cb2f6..0119bae 100644
--- a/src/lib/admission-blocks.js
+++ b/2017/src/lib/admission-blocks.js
@@ -1,13 +1,10 @@
-// Vietnamese university admission-block ("khối thi") definitions.
+// Vietnamese university admission-block ("khối thi") definitions for 2017.
// Each block is the sum of 3 subjects; we only compute a block when ALL three
// subject scores exist for the student.
-//
-// One list serves both exam years. computeBlocks() skips any block with a
-// missing subject score, so blocks needing GDCD self-exclude on 2016 rows and
-// blocks needing Tiếng Đức / Nhật self-exclude wherever those weren't sat —
-// no per-year branching required.
-//
-// Based on Circular 03/2017/TT-BGDĐT.
+
+// Full 2017 admission-block list (Circular 03/2017/TT-BGDĐT) restricted to
+// the 12 subjects available in our DB. Blocks needing Tiếng Đức / Nhật are
+// excluded — neither language appears in any source file.
export const ADMISSION_BLOCKS = [
{ code: "A00", subjects: ["toan", "vat_ly", "hoa_hoc"], label: "Toán + Lý + Hóa" },
{ code: "A01", subjects: ["toan", "vat_ly", "tieng_anh"], label: "Toán + Lý + Anh" },
@@ -58,14 +55,6 @@ export const ADMISSION_BLOCKS = [
{ code: "D13", subjects: ["ngu_van", "sinh_hoc", "tieng_anh"], label: "Văn + Sinh + Anh" },
{ code: "D14", subjects: ["ngu_van", "lich_su", "tieng_anh"], label: "Văn + Sử + Anh" },
{ code: "D15", subjects: ["ngu_van", "dia_ly", "tieng_anh"], label: "Văn + Địa + Anh" },
-
- // German and Japanese blocks. Previously omitted because neither language
- // appeared in the 2017 database — but that was a parser gap, not reality:
- // unifying the subject patterns recovered German and Japanese scores in every
- // dataset. Students who sat neither are unaffected, since computeBlocks()
- // skips blocks with a missing subject.
- { code: "D05", subjects: ["toan", "ngu_van", "tieng_duc"], label: "Toán + Văn + Đức" },
- { code: "D06", subjects: ["toan", "ngu_van", "tieng_nhat"], label: "Toán + Văn + Nhật" },
];
// Returns { code, label, total, parts:[{key,score}] } for every block where
diff --git a/src/main.jsx b/2017/src/main.jsx
similarity index 100%
rename from src/main.jsx
rename to 2017/src/main.jsx
diff --git a/2017/tools/xlsxread/Cargo.lock b/2017/tools/xlsxread/Cargo.lock
new file mode 100644
index 0000000..e57f452
--- /dev/null
+++ b/2017/tools/xlsxread/Cargo.lock
@@ -0,0 +1,1159 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[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 = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bzip2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
+dependencies = [
+ "bzip2-sys",
+]
+
+[[package]]
+name = "bzip2-sys"
+version = "0.1.13+1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
+name = "calamine"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1"
+dependencies = [
+ "byteorder",
+ "codepage",
+ "encoding_rs",
+ "log",
+ "quick-xml",
+ "serde",
+ "zip",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.62"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "codepage"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4"
+dependencies = [
+ "encoding_rs",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "constant_time_eq"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "deflate64"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown 0.14.5",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "lzma-rs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
+dependencies = [
+ "byteorder",
+ "crc",
+]
+
+[[package]]
+name = "lzma-sys"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "pbkdf2"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
+dependencies = [
+ "digest",
+ "hmac",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
+dependencies = [
+ "encoding_rs",
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[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 = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[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 = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "xlsxread"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "calamine",
+ "clap",
+ "glob",
+ "regex",
+ "rusqlite",
+ "serde",
+ "thiserror 1.0.69",
+ "toml",
+ "unicode-normalization",
+ "zip",
+]
+
+[[package]]
+name = "xz2"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
+dependencies = [
+ "lzma-sys",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zip"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
+dependencies = [
+ "aes",
+ "arbitrary",
+ "bzip2",
+ "constant_time_eq",
+ "crc32fast",
+ "crossbeam-utils",
+ "deflate64",
+ "displaydoc",
+ "flate2",
+ "getrandom",
+ "hmac",
+ "indexmap",
+ "lzma-rs",
+ "memchr",
+ "pbkdf2",
+ "sha1",
+ "thiserror 2.0.18",
+ "time",
+ "xz2",
+ "zeroize",
+ "zopfli",
+ "zstd",
+]
+
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/2017/tools/xlsxread/Cargo.toml b/2017/tools/xlsxread/Cargo.toml
new file mode 100644
index 0000000..d23dcca
--- /dev/null
+++ b/2017/tools/xlsxread/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "xlsxread"
+version = "0.1.0"
+edition = "2021"
+description = "Rust CLI replacing SheetJS xlsx build scripts for thptqg2017/thptqg2016"
+
+[dependencies]
+calamine = "0.26"
+rusqlite = { version = "0.32", features = ["bundled"] }
+clap = { version = "4", features = ["derive"] }
+serde = { version = "1", features = ["derive"] }
+toml = "0.8"
+regex = "1"
+unicode-normalization = "0.1"
+thiserror = "1"
+anyhow = "1"
+glob = "0.3"
+
+# zip is already a transitive dep of calamine; pin explicitly so tests can use it
+[dev-dependencies]
+zip = "2"
+rusqlite = { version = "0.32", features = ["bundled"] }
+
+[[bin]]
+name = "xlsxread"
+path = "src/main.rs"
+
+[[test]]
+name = "golden"
+path = "tests/golden.rs"
diff --git a/2017/tools/xlsxread/configs/thptqg2017-data-old.toml b/2017/tools/xlsxread/configs/thptqg2017-data-old.toml
new file mode 100644
index 0000000..03d51be
--- /dev/null
+++ b/2017/tools/xlsxread/configs/thptqg2017-data-old.toml
@@ -0,0 +1,75 @@
+# Config for data-old/ — 63 .xlsx files (pre-baotintuc refresh)
+# Sheet mode: "first" — single-sheet workbooks, never hit 65k row cap
+# SBD validation: require ^\d+$ (build-database-old.js:55 guard)
+# Blank row strip: off (no explicit blank-skip in build-database-old.js)
+
+[reader]
+sheet_mode = "first"
+strip_blank_rows = false
+
+[columns]
+ho_ten = 0
+ngay_sinh = 1
+so_bao_danh = 2
+diem_thi = 3
+
+[validation]
+require_numeric_sbd = true
+require_nonempty_name = true
+require_nonempty_sbd = true
+
+[header]
+tokens = ["HO_TEN", "HỌ TÊN", "STT"]
+
+[schema]
+ddl = """
+CREATE TABLE student (
+ so_bao_danh TEXT PRIMARY KEY,
+ ho_ten TEXT NOT NULL,
+ ho_ten_ascii TEXT NOT NULL,
+ ngay_sinh TEXT,
+ toan REAL,
+ ngu_van REAL,
+ vat_ly REAL,
+ hoa_hoc REAL,
+ sinh_hoc REAL,
+ khtn REAL,
+ lich_su REAL,
+ dia_ly REAL,
+ gdcd REAL,
+ khxh REAL,
+ tieng_anh REAL,
+ tieng_phap REAL,
+ tieng_nga REAL,
+ tieng_trung REAL
+);
+CREATE INDEX idx_ho_ten ON student(ho_ten);
+CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
+"""
+
+[scores]
+toan = 'Toán:\s*(\d+(?:\.\d+)?)'
+ngu_van = 'Ngữ văn:\s*(\d+(?:\.\d+)?)'
+vat_ly = 'Vật lí:\s*(\d+(?:\.\d+)?)'
+hoa_hoc = 'Hóa học:\s*(\d+(?:\.\d+)?)'
+sinh_hoc = 'Sinh học:\s*(\d+(?:\.\d+)?)'
+khtn = 'KHTN:\s*(\d+(?:\.\d+)?)'
+lich_su = 'Lịch sử:\s*(\d+(?:\.\d+)?)'
+dia_ly = 'Địa lí:\s*(\d+(?:\.\d+)?)'
+gdcd = 'GDCD:\s*(\d+(?:\.\d+)?)'
+khxh = 'KHXH:\s*(\d+(?:\.\d+)?)'
+tieng_anh = 'Tiếng Anh:\s*(\d+(?:\.\d+)?)'
+tieng_phap = 'Tiếng Pháp:\s*(\d+(?:\.\d+)?)'
+tieng_nga = 'Tiếng Nga:\s*(\d+(?:\.\d+)?)'
+tieng_trung = 'Tiếng Trung:\s*(\d+(?:\.\d+)?)'
+
+[insert]
+sql = """
+INSERT OR REPLACE INTO student
+ (so_bao_danh, ho_ten, ho_ten_ascii, ngay_sinh,
+ toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
+ lich_su, dia_ly, gdcd, khxh,
+ tieng_anh, tieng_phap, tieng_nga, tieng_trung)
+VALUES
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+"""
diff --git a/2017/tools/xlsxread/configs/thptqg2017-data-old2.toml b/2017/tools/xlsxread/configs/thptqg2017-data-old2.toml
new file mode 100644
index 0000000..2c3f52c
--- /dev/null
+++ b/2017/tools/xlsxread/configs/thptqg2017-data-old2.toml
@@ -0,0 +1,76 @@
+# Config for data-old2/ — 54 .xlsx files (corrected-export set)
+# Sheet mode: "all" — HCM (24.HCM_UTLQ.xlsx) overflows into Sheet2 (+6,446 rows)
+# SBD validation: require ^\d+$ (build-database-old2.js:57 guard)
+# Blank row strip: true — skip fully blank rows BEFORE counting sourceRows
+# (build-database-old2.js:50-51: blank row check before sourceRows++)
+
+[reader]
+sheet_mode = "all"
+strip_blank_rows = true
+
+[columns]
+ho_ten = 0
+ngay_sinh = 1
+so_bao_danh = 2
+diem_thi = 3
+
+[validation]
+require_numeric_sbd = true
+require_nonempty_name = true
+require_nonempty_sbd = true
+
+[header]
+tokens = ["HO_TEN", "HỌ TÊN", "STT"]
+
+[schema]
+ddl = """
+CREATE TABLE student (
+ so_bao_danh TEXT PRIMARY KEY,
+ ho_ten TEXT NOT NULL,
+ ho_ten_ascii TEXT NOT NULL,
+ ngay_sinh TEXT,
+ toan REAL,
+ ngu_van REAL,
+ vat_ly REAL,
+ hoa_hoc REAL,
+ sinh_hoc REAL,
+ khtn REAL,
+ lich_su REAL,
+ dia_ly REAL,
+ gdcd REAL,
+ khxh REAL,
+ tieng_anh REAL,
+ tieng_phap REAL,
+ tieng_nga REAL,
+ tieng_trung REAL
+);
+CREATE INDEX idx_ho_ten ON student(ho_ten);
+CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
+"""
+
+[scores]
+toan = 'Toán:\s*(\d+(?:\.\d+)?)'
+ngu_van = 'Ngữ văn:\s*(\d+(?:\.\d+)?)'
+vat_ly = 'Vật lí:\s*(\d+(?:\.\d+)?)'
+hoa_hoc = 'Hóa học:\s*(\d+(?:\.\d+)?)'
+sinh_hoc = 'Sinh học:\s*(\d+(?:\.\d+)?)'
+khtn = 'KHTN:\s*(\d+(?:\.\d+)?)'
+lich_su = 'Lịch sử:\s*(\d+(?:\.\d+)?)'
+dia_ly = 'Địa lí:\s*(\d+(?:\.\d+)?)'
+gdcd = 'GDCD:\s*(\d+(?:\.\d+)?)'
+khxh = 'KHXH:\s*(\d+(?:\.\d+)?)'
+tieng_anh = 'Tiếng Anh:\s*(\d+(?:\.\d+)?)'
+tieng_phap = 'Tiếng Pháp:\s*(\d+(?:\.\d+)?)'
+tieng_nga = 'Tiếng Nga:\s*(\d+(?:\.\d+)?)'
+tieng_trung = 'Tiếng Trung:\s*(\d+(?:\.\d+)?)'
+
+[insert]
+sql = """
+INSERT OR REPLACE INTO student
+ (so_bao_danh, ho_ten, ho_ten_ascii, ngay_sinh,
+ toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
+ lich_su, dia_ly, gdcd, khxh,
+ tieng_anh, tieng_phap, tieng_nga, tieng_trung)
+VALUES
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+"""
diff --git a/2017/tools/xlsxread/configs/thptqg2017-data.toml b/2017/tools/xlsxread/configs/thptqg2017-data.toml
new file mode 100644
index 0000000..c879f7b
--- /dev/null
+++ b/2017/tools/xlsxread/configs/thptqg2017-data.toml
@@ -0,0 +1,75 @@
+# Config for data/ — 63 .xls files from baotintuc.vn
+# Sheet mode: "all" because Hà Nội and HCM overflow into Sheet2 (65k row cap)
+# SBD validation: no numeric guard (build-database.js does not apply ^\d+$)
+# Blank row strip: off
+
+[reader]
+sheet_mode = "all"
+strip_blank_rows = false
+
+[columns]
+ho_ten = 0
+ngay_sinh = 1
+so_bao_danh = 2
+diem_thi = 3
+
+[validation]
+require_numeric_sbd = false
+require_nonempty_name = true
+require_nonempty_sbd = true
+
+[header]
+tokens = ["HO_TEN", "HỌ TÊN", "STT"]
+
+[schema]
+ddl = """
+CREATE TABLE student (
+ so_bao_danh TEXT PRIMARY KEY,
+ ho_ten TEXT NOT NULL,
+ ho_ten_ascii TEXT NOT NULL,
+ ngay_sinh TEXT,
+ toan REAL,
+ ngu_van REAL,
+ vat_ly REAL,
+ hoa_hoc REAL,
+ sinh_hoc REAL,
+ khtn REAL,
+ lich_su REAL,
+ dia_ly REAL,
+ gdcd REAL,
+ khxh REAL,
+ tieng_anh REAL,
+ tieng_phap REAL,
+ tieng_nga REAL,
+ tieng_trung REAL
+);
+CREATE INDEX idx_ho_ten ON student(ho_ten);
+CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
+"""
+
+[scores]
+toan = 'Toán:\s*(\d+(?:\.\d+)?)'
+ngu_van = 'Ngữ văn:\s*(\d+(?:\.\d+)?)'
+vat_ly = 'Vật lí:\s*(\d+(?:\.\d+)?)'
+hoa_hoc = 'Hóa học:\s*(\d+(?:\.\d+)?)'
+sinh_hoc = 'Sinh học:\s*(\d+(?:\.\d+)?)'
+khtn = 'KHTN:\s*(\d+(?:\.\d+)?)'
+lich_su = 'Lịch sử:\s*(\d+(?:\.\d+)?)'
+dia_ly = 'Địa lí:\s*(\d+(?:\.\d+)?)'
+gdcd = 'GDCD:\s*(\d+(?:\.\d+)?)'
+khxh = 'KHXH:\s*(\d+(?:\.\d+)?)'
+tieng_anh = 'Tiếng Anh:\s*(\d+(?:\.\d+)?)'
+tieng_phap = 'Tiếng Pháp:\s*(\d+(?:\.\d+)?)'
+tieng_nga = 'Tiếng Nga:\s*(\d+(?:\.\d+)?)'
+tieng_trung = 'Tiếng Trung:\s*(\d+(?:\.\d+)?)'
+
+[insert]
+sql = """
+INSERT OR REPLACE INTO student
+ (so_bao_danh, ho_ten, ho_ten_ascii, ngay_sinh,
+ toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
+ lich_su, dia_ly, gdcd, khxh,
+ tieng_anh, tieng_phap, tieng_nga, tieng_trung)
+VALUES
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+"""
diff --git a/2017/tools/xlsxread/src/audit.rs b/2017/tools/xlsxread/src/audit.rs
new file mode 100644
index 0000000..30ec8f4
--- /dev/null
+++ b/2017/tools/xlsxread/src/audit.rs
@@ -0,0 +1,174 @@
+/// Audit subcommand: replicates audit-row-counts.js exactly.
+///
+/// Reads all .xlsx files from the input directory (sheet 0 only, matching the
+/// JS script's behaviour at audit-row-counts.js:33), collects distinct SBDs
+/// into a HashSet, then queries `SELECT COUNT(*) FROM student` from the DB.
+/// Prints the same lines as audit-row-counts.js:54-62 and exits 0 on match,
+/// 1 on mismatch.
+use std::collections::HashSet;
+use std::path::Path;
+
+use calamine::{open_workbook_auto, Data, Reader};
+
+use crate::config::DatasetConfig;
+use crate::error::BuildError;
+use crate::reader::is_header_row;
+
+// ---------------------------------------------------------------------------
+// Audit result
+// ---------------------------------------------------------------------------
+
+pub struct AuditResult {
+ pub total_data_rows: u64,
+ pub both_empty: u64,
+ pub empty_name: u64,
+ pub empty_sbd: u64,
+ pub distinct_sbds: usize,
+ pub db_count: i64,
+ pub matched: bool,
+}
+
+// ---------------------------------------------------------------------------
+// Main audit logic
+// ---------------------------------------------------------------------------
+
+/// Collect distinct SBDs from all xlsx files in `input_dir`, query `db_path`,
+/// print the audit report and return the result.
+///
+/// The JS script reads only sheet 0 for every file (audit-row-counts.js:33).
+/// Unlike build-database.js, the audit script does NOT iterate all sheets.
+pub fn run_audit(
+ input_dir: &Path,
+ db_path: &Path,
+ cfg: &DatasetConfig,
+) -> Result
{
+ // Collect .xlsx files (audit-row-counts.js only checks .xlsx — line 15)
+ let mut files: Vec = std::fs::read_dir(input_dir)
+ .map_err(|e| BuildError::Io {
+ path: input_dir.display().to_string(),
+ source: e,
+ })?
+ .filter_map(|e| e.ok())
+ .map(|e| e.path())
+ .filter(|p| {
+ p.is_file()
+ && p.extension()
+ .and_then(|e| e.to_str())
+ .map(|e| e.eq_ignore_ascii_case("xlsx"))
+ .unwrap_or(false)
+ })
+ .collect();
+ files.sort();
+
+ let mut all_sbd: HashSet = HashSet::new();
+ let mut total_data_rows: u64 = 0;
+ let mut empty_name: u64 = 0;
+ let mut empty_sbd: u64 = 0;
+ let mut both_empty: u64 = 0;
+
+ for file in &files {
+ let path_str = file.display().to_string();
+ let mut workbook = open_workbook_auto(file).map_err(|e| BuildError::Calamine {
+ path: path_str.clone(),
+ source: e,
+ })?;
+
+ let sheet_names = workbook.sheet_names().to_vec();
+ if sheet_names.is_empty() {
+ continue;
+ }
+
+ // audit-row-counts.js reads only sheet 0 (line 33: wb.SheetNames[0])
+ let range =
+ workbook
+ .worksheet_range(&sheet_names[0])
+ .map_err(|e| BuildError::Calamine {
+ path: path_str.clone(),
+ source: e,
+ })?;
+
+ let mut first_row = true;
+ for raw in range.rows() {
+ let row: Vec = raw.to_vec();
+
+ // Skip header row on first row only
+ if first_row {
+ first_row = false;
+ if is_header_row(&row, &cfg.header) {
+ continue;
+ }
+ }
+
+ total_data_rows += 1;
+
+ let ho_ten = row
+ .get(cfg.columns.ho_ten)
+ .map(|c| c.to_string().trim().to_owned())
+ .unwrap_or_default();
+ let sbd = row
+ .get(cfg.columns.so_bao_danh)
+ .map(|c| c.to_string().trim().to_owned())
+ .unwrap_or_default();
+
+ if ho_ten.is_empty() && sbd.is_empty() {
+ both_empty += 1;
+ continue;
+ }
+ if ho_ten.is_empty() {
+ empty_name += 1;
+ }
+ if sbd.is_empty() {
+ empty_sbd += 1;
+ }
+ if !sbd.is_empty() {
+ all_sbd.insert(sbd);
+ }
+ }
+ }
+
+ // Query DB count
+ let conn =
+ rusqlite::Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
+ let db_count: i64 = conn.query_row("SELECT COUNT(*) FROM student", [], |row| row.get(0))?;
+
+ let distinct_sbds = all_sbd.len();
+ let matched = distinct_sbds as i64 == db_count;
+
+ Ok(AuditResult {
+ total_data_rows,
+ both_empty,
+ empty_name,
+ empty_sbd,
+ distinct_sbds,
+ db_count,
+ matched,
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Print audit report — mirrors audit-row-counts.js:54-62 exactly
+// ---------------------------------------------------------------------------
+
+pub fn print_audit_report(r: &AuditResult) {
+ println!("=== Source vs DB ===");
+ println!(
+ "Source: total data rows across all files: {}",
+ r.total_data_rows
+ );
+ println!(
+ "Source: rows with empty name AND sbd (skipped): {}",
+ r.both_empty
+ );
+ println!("Source: rows with missing name only: {}", r.empty_name);
+ println!("Source: rows with missing sbd only: {}", r.empty_sbd);
+ println!("Source: distinct SBDs: {}", r.distinct_sbds);
+ println!("DB: row count: {}", r.db_count);
+ println!(
+ "Match: {}",
+ if r.matched {
+ "YES — all unique SBDs accounted for".to_string()
+ } else {
+ format!("NO — gap of {}", r.distinct_sbds as i64 - r.db_count)
+ }
+ );
+}
diff --git a/2017/tools/xlsxread/src/cli.rs b/2017/tools/xlsxread/src/cli.rs
new file mode 100644
index 0000000..bce8285
--- /dev/null
+++ b/2017/tools/xlsxread/src/cli.rs
@@ -0,0 +1,48 @@
+/// CLI argument structs via clap derive.
+use std::path::PathBuf;
+
+use clap::{Parser, Subcommand};
+
+#[derive(Parser)]
+#[command(
+ name = "xlsxread",
+ version,
+ about = "Read .xls/.xlsx files and build SQLite databases for thptqg datasets"
+)]
+pub struct Cli {
+ #[command(subcommand)]
+ pub cmd: Cmd,
+}
+
+#[derive(Subcommand)]
+pub enum Cmd {
+ /// Read input spreadsheets and write a SQLite database
+ Build {
+ /// Path to the dataset TOML config file
+ #[arg(long)]
+ schema: PathBuf,
+
+ /// Directory containing the .xls / .xlsx source files
+ #[arg(long)]
+ input: PathBuf,
+
+ /// Output SQLite database path
+ #[arg(long)]
+ output: PathBuf,
+ },
+
+ /// Audit: compare distinct SBD count from xlsx files vs DB row count
+ Audit {
+ /// Path to the dataset TOML config file
+ #[arg(long)]
+ schema: PathBuf,
+
+ /// Directory containing the .xlsx source files
+ #[arg(long)]
+ input: PathBuf,
+
+ /// SQLite database to compare against
+ #[arg(long)]
+ db: PathBuf,
+ },
+}
diff --git a/2017/tools/xlsxread/src/config.rs b/2017/tools/xlsxread/src/config.rs
new file mode 100644
index 0000000..8a2cd4c
--- /dev/null
+++ b/2017/tools/xlsxread/src/config.rs
@@ -0,0 +1,146 @@
+use std::collections::HashMap;
+use std::fs;
+use std::path::Path;
+
+use serde::Deserialize;
+
+use crate::error::BuildError;
+
+// ---------------------------------------------------------------------------
+// Top-level dataset configuration loaded from a .toml file
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct DatasetConfig {
+ pub reader: ReaderCfg,
+ pub columns: ColumnMap,
+ pub validation: ValidationCfg,
+ pub header: HeaderCfg,
+ pub schema: SchemaCfg,
+ /// field name → regex source string (one entry per scoreable subject)
+ pub scores: HashMap,
+ pub insert: InsertCfg,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct ReaderCfg {
+ /// "all" → iterate every sheet (handles HCM/HN overflow); "first" → sheet 0 only
+ pub sheet_mode: SheetMode,
+ /// If true, skip rows where every cell is empty/null before counting (data-old2 quirk)
+ pub strip_blank_rows: bool,
+}
+
+#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum SheetMode {
+ All,
+ First,
+}
+
+/// Zero-indexed column positions in the source spreadsheet row.
+#[derive(Debug, Deserialize, Clone)]
+pub struct ColumnMap {
+ pub ho_ten: usize,
+ pub ngay_sinh: usize,
+ pub so_bao_danh: usize,
+ pub diem_thi: usize,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct ValidationCfg {
+ /// build-database-old.js / -old2.js require soBaoDanh to match ^\d+$
+ pub require_numeric_sbd: bool,
+ pub require_nonempty_name: bool,
+ pub require_nonempty_sbd: bool,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct HeaderCfg {
+ /// Tokens to match against row[0].to_uppercase() to detect a header row
+ pub tokens: Vec,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct SchemaCfg {
+ /// DDL executed verbatim before inserts (CREATE TABLE + CREATE INDEX)
+ pub ddl: String,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct InsertCfg {
+ /// Parameterised INSERT OR REPLACE SQL using :named_param style
+ pub sql: String,
+}
+
+// ---------------------------------------------------------------------------
+// Loader
+// ---------------------------------------------------------------------------
+
+pub fn load_config(path: &Path) -> Result {
+ let text = fs::read_to_string(path).map_err(|e| BuildError::Io {
+ path: path.display().to_string(),
+ source: e,
+ })?;
+ let cfg: DatasetConfig = toml::from_str(&text)?;
+ Ok(cfg)
+}
+
+// ---------------------------------------------------------------------------
+// Unit tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const SAMPLE_TOML: &str = r#"
+[reader]
+sheet_mode = "all"
+strip_blank_rows = false
+
+[columns]
+ho_ten = 0
+ngay_sinh = 1
+so_bao_danh = 2
+diem_thi = 3
+
+[validation]
+require_numeric_sbd = false
+require_nonempty_name = true
+require_nonempty_sbd = true
+
+[header]
+tokens = ["HO_TEN", "HỌ TÊN", "STT"]
+
+[schema]
+ddl = "CREATE TABLE student (so_bao_danh TEXT PRIMARY KEY);"
+
+[scores]
+toan = 'Toán:\s*(\d+(?:\.\d+)?)'
+ngu_van = 'Ngữ văn:\s*(\d+(?:\.\d+)?)'
+
+[insert]
+sql = "INSERT OR REPLACE INTO student (so_bao_danh) VALUES (:so_bao_danh)"
+"#;
+
+ #[test]
+ fn config_round_trip() {
+ let cfg: DatasetConfig = toml::from_str(SAMPLE_TOML).expect("parse failed");
+ assert_eq!(cfg.reader.sheet_mode, SheetMode::All);
+ assert!(!cfg.reader.strip_blank_rows);
+ assert_eq!(cfg.columns.ho_ten, 0);
+ assert_eq!(cfg.columns.diem_thi, 3);
+ assert!(!cfg.validation.require_numeric_sbd);
+ assert!(cfg.validation.require_nonempty_name);
+ assert_eq!(cfg.header.tokens.len(), 3);
+ assert!(cfg.scores.contains_key("toan"));
+ assert!(cfg.scores.contains_key("ngu_van"));
+ }
+
+ #[test]
+ fn config_first_sheet_mode() {
+ let toml_str = SAMPLE_TOML.replace(r#"sheet_mode = "all""#, r#"sheet_mode = "first""#);
+ let cfg: DatasetConfig = toml::from_str(&toml_str).expect("parse failed");
+ assert_eq!(cfg.reader.sheet_mode, SheetMode::First);
+ }
+}
diff --git a/2017/tools/xlsxread/src/error.rs b/2017/tools/xlsxread/src/error.rs
new file mode 100644
index 0000000..e15edc9
--- /dev/null
+++ b/2017/tools/xlsxread/src/error.rs
@@ -0,0 +1,34 @@
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum BuildError {
+ #[error("I/O error for {path}: {source}")]
+ Io {
+ path: String,
+ #[source]
+ source: std::io::Error,
+ },
+
+ #[error("Calamine error for {path}: {source}")]
+ Calamine {
+ path: String,
+ #[source]
+ source: calamine::Error,
+ },
+
+ #[error("SQLite error: {0}")]
+ Sqlite(#[from] rusqlite::Error),
+
+ #[error("Config parse error: {0}")]
+ Config(#[from] toml::de::Error),
+
+ #[error("Regex compile error for pattern '{pattern}': {source}")]
+ Regex {
+ pattern: String,
+ #[source]
+ source: regex::Error,
+ },
+
+ #[error("Schema has no sheets in file: {0}")]
+ NoSheets(String),
+}
diff --git a/2017/tools/xlsxread/src/lib.rs b/2017/tools/xlsxread/src/lib.rs
new file mode 100644
index 0000000..948fa08
--- /dev/null
+++ b/2017/tools/xlsxread/src/lib.rs
@@ -0,0 +1,9 @@
+/// Public library interface for integration tests.
+/// The binary entry point is src/main.rs; this file re-exports the internal
+/// modules so tests/golden.rs can call them without going through the CLI.
+pub mod audit;
+pub mod config;
+pub mod error;
+pub mod reader;
+pub mod transform;
+pub mod writer;
diff --git a/2017/tools/xlsxread/src/main.rs b/2017/tools/xlsxread/src/main.rs
new file mode 100644
index 0000000..963f8f3
--- /dev/null
+++ b/2017/tools/xlsxread/src/main.rs
@@ -0,0 +1,193 @@
+/// xlsxread — Rust CLI replacing the SheetJS xlsx build scripts.
+///
+/// Subcommands:
+/// build — read .xls/.xlsx files → write SQLite DB
+/// audit — compare distinct SBD count from xlsx vs DB row count
+///
+/// Library modules are declared in lib.rs; main.rs only adds the CLI layer.
+mod cli;
+
+use std::path::Path;
+
+use anyhow::{Context, Result};
+use clap::Parser;
+
+use cli::{Cli, Cmd};
+use xlsxread::audit;
+use xlsxread::config::load_config;
+use xlsxread::reader::{is_all_blank, process_file};
+use xlsxread::transform::{validate_row, CompiledPatterns, SkipReason};
+use xlsxread::writer::{finish_db, insert_row, open_db, SCORE_FIELDS};
+
+fn main() -> Result<()> {
+ let cli = Cli::parse();
+
+ match cli.cmd {
+ Cmd::Build {
+ schema,
+ input,
+ output,
+ } => {
+ run_build(&schema, &input, &output)?;
+ }
+ Cmd::Audit { schema, input, db } => {
+ let cfg = load_config(&schema)
+ .with_context(|| format!("Failed to load config: {}", schema.display()))?;
+ let result = audit::run_audit(&input, &db, &cfg).with_context(|| "Audit failed")?;
+ audit::print_audit_report(&result);
+ if !result.matched {
+ std::process::exit(1);
+ }
+ }
+ }
+
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Build subcommand
+// ---------------------------------------------------------------------------
+
+fn run_build(schema_path: &Path, input_dir: &Path, output_path: &Path) -> Result<()> {
+ let cfg = load_config(schema_path)
+ .with_context(|| format!("Failed to load config: {}", schema_path.display()))?;
+
+ // Compile score regexes once at startup
+ let patterns =
+ CompiledPatterns::new(&cfg.scores).with_context(|| "Failed to compile score regexes")?;
+
+ // Collect input files (.xls and .xlsx), sorted for deterministic order
+ let mut files: Vec = std::fs::read_dir(input_dir)
+ .with_context(|| format!("Cannot read input dir: {}", input_dir.display()))?
+ .filter_map(|e| e.ok())
+ .map(|e| e.path())
+ .filter(|p| {
+ p.is_file()
+ && p.extension()
+ .and_then(|e| e.to_str())
+ .map(|e| {
+ let lower = e.to_lowercase();
+ lower == "xls" || lower == "xlsx"
+ })
+ .unwrap_or(false)
+ })
+ .collect();
+ files.sort();
+
+ let dataset_label = input_dir
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("data");
+
+ println!(
+ "[build] {dataset_label}/ → {} ({} files)",
+ output_path.display(),
+ files.len()
+ );
+
+ // Open (or recreate) DB and apply DDL
+ let conn = open_db(output_path, &cfg)
+ .with_context(|| format!("Failed to open DB: {}", output_path.display()))?;
+
+ let mut total_source_rows: u64 = 0;
+ let mut total_skipped: u64 = 0;
+ let mut total_errors: u64 = 0;
+
+ let is_old2 = dataset_label.contains("old2");
+ let strip_blank = cfg.reader.strip_blank_rows;
+
+ // Single transaction over all files — mirrors the Node `db.transaction(() => { ... })()`
+ conn.execute_batch("BEGIN")?;
+
+ for file in &files {
+ let base = file
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("?")
+ .to_owned();
+ let mut file_rows: u64 = 0;
+ let mut file_skipped: u64 = 0;
+ let mut file_errors: u64 = 0;
+
+ let process_result = process_file(file, &cfg, |_sheet_idx, raw| {
+ // data-old2: skip fully blank rows BEFORE counting sourceRows
+ let all_blank = is_all_blank(raw);
+ if strip_blank && all_blank {
+ return;
+ }
+
+ total_source_rows += 1;
+
+ let ho_ten = raw
+ .get(cfg.columns.ho_ten)
+ .map(|c| c.to_string().trim().to_owned())
+ .unwrap_or_default();
+ let so_bao_danh = raw
+ .get(cfg.columns.so_bao_danh)
+ .map(|c| c.to_string().trim().to_owned())
+ .unwrap_or_default();
+
+ match validate_row(
+ &ho_ten,
+ &so_bao_danh,
+ &cfg.validation,
+ strip_blank,
+ all_blank,
+ ) {
+ Err(SkipReason::BlankRow) => {
+ // Already guarded above; won't reach here
+ }
+ Err(_) => {
+ file_skipped += 1;
+ return;
+ }
+ Ok(()) => {}
+ }
+
+ let parsed = xlsxread::transform::transform_row(raw, &cfg, &patterns);
+
+ match insert_row(&conn, &cfg.insert.sql, &parsed, SCORE_FIELDS) {
+ Ok(()) => {
+ file_rows += 1;
+ }
+ Err(e) => {
+ file_errors += 1;
+ if total_errors + file_errors <= 5 {
+ eprintln!(" [warn] {base}: {e}");
+ }
+ }
+ }
+ });
+
+ match process_result {
+ Ok(_) => {}
+ Err(e) => {
+ eprintln!(" [error] {base}: {e}");
+ file_errors += 1;
+ }
+ }
+
+ total_skipped += file_skipped;
+ total_errors += file_errors;
+
+ // Per-file row count line — mirrors `console.log(` ${base}: ${fileRows} rows`)`
+ println!(" {base}: {file_rows} rows");
+ }
+
+ conn.execute_batch("COMMIT")?;
+
+ // VACUUM + stats output
+ finish_db(
+ &conn,
+ output_path,
+ total_source_rows,
+ total_skipped,
+ total_errors,
+ dataset_label,
+ files.len(),
+ is_old2,
+ )
+ .with_context(|| "Failed to finalise DB")?;
+
+ Ok(())
+}
diff --git a/2017/tools/xlsxread/src/reader.rs b/2017/tools/xlsxread/src/reader.rs
new file mode 100644
index 0000000..ac71da1
--- /dev/null
+++ b/2017/tools/xlsxread/src/reader.rs
@@ -0,0 +1,197 @@
+/// Spreadsheet reader: wraps calamine to iterate rows across sheets.
+///
+/// Sheet selection mirrors the JS scripts:
+/// - sheet_mode = "all" → iterate every sheet (handles HCM/HN 65k overflow in data/)
+/// - sheet_mode = "first" → sheet 0 only (data-old/)
+///
+/// Header detection mirrors build-lib.js isHeaderRow:
+/// row[0].toUpperCase() in {"HO_TEN", "HỌ TÊN", "STT"}
+use std::path::Path;
+
+use calamine::{open_workbook_auto, Data, Reader, Sheets};
+
+use crate::config::{DatasetConfig, HeaderCfg, SheetMode};
+use crate::error::BuildError;
+
+// ---------------------------------------------------------------------------
+// Public row representation from calamine
+// ---------------------------------------------------------------------------
+
+pub type RawRow = Vec;
+
+// ---------------------------------------------------------------------------
+// Header detection — mirrors build-lib.js isHeaderRow
+// ---------------------------------------------------------------------------
+
+/// Returns true when the first cell (uppercased) matches one of the configured
+/// header tokens. Used to skip the header row on the first row of each sheet.
+pub fn is_header_row(row: &[Data], header_cfg: &HeaderCfg) -> bool {
+ if row.len() < 3 {
+ return false;
+ }
+ let first = row[0].to_string().trim().to_uppercase();
+ header_cfg.tokens.iter().any(|t| t.to_uppercase() == first)
+}
+
+// ---------------------------------------------------------------------------
+// All-blank row check (data-old2: strip_blank_rows)
+// ---------------------------------------------------------------------------
+
+pub fn is_all_blank(row: &[Data]) -> bool {
+ row.iter()
+ .all(|c| matches!(c, Data::Empty) || c.to_string().trim().is_empty())
+}
+
+// ---------------------------------------------------------------------------
+// File processor — yields all data rows from the file
+// ---------------------------------------------------------------------------
+
+/// Process one spreadsheet file, calling `on_row` for each data row.
+///
+/// `on_row` receives `(sheet_index, row_index_in_sheet, raw_row)` where
+/// `row_index_in_sheet` is 0-based AFTER the header has been consumed.
+/// Returns `(sheets_seen, total_rows_yielded)`.
+pub fn process_file(
+ path: &Path,
+ cfg: &DatasetConfig,
+ mut on_row: F,
+) -> Result<(usize, usize), BuildError>
+where
+ F: FnMut(usize, &RawRow),
+{
+ let path_str = path.display().to_string();
+
+ // calamine::open_workbook_auto dispatches on file extension
+ let mut workbook: Sheets<_> = open_workbook_auto(path).map_err(|e| BuildError::Calamine {
+ path: path_str.clone(),
+ source: e,
+ })?;
+
+ let sheet_names: Vec = workbook.sheet_names().to_vec();
+ if sheet_names.is_empty() {
+ return Err(BuildError::NoSheets(path_str.clone()));
+ }
+
+ // Sheet selection per config
+ let sheets_to_read: Vec = match cfg.reader.sheet_mode {
+ SheetMode::All => sheet_names.clone(),
+ SheetMode::First => vec![sheet_names[0].clone()],
+ };
+
+ let mut total_rows = 0usize;
+
+ for (sheet_idx, sheet_name) in sheets_to_read.iter().enumerate() {
+ let range = workbook
+ .worksheet_range(sheet_name)
+ .map_err(|e| BuildError::Calamine {
+ path: path_str.clone(),
+ source: e,
+ })?;
+
+ let mut first_row = true;
+
+ for raw in range.rows() {
+ let row: RawRow = raw.to_vec();
+
+ // Skip header row on first row of each sheet (matches JS: `if (i === 0 && isHeaderRow(...))`)
+ if first_row {
+ first_row = false;
+ if is_header_row(&row, &cfg.header) {
+ continue;
+ }
+ }
+
+ on_row(sheet_idx, &row);
+ total_rows += 1;
+ }
+ }
+
+ Ok((sheets_to_read.len(), total_rows))
+}
+
+// ---------------------------------------------------------------------------
+// Unit tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::HeaderCfg;
+
+ fn hdr(tokens: &[&str]) -> HeaderCfg {
+ HeaderCfg {
+ tokens: tokens.iter().map(|s| s.to_string()).collect(),
+ }
+ }
+
+ #[test]
+ fn header_detects_ho_ten() {
+ let row = vec![
+ Data::String("HO_TEN".into()),
+ Data::String("NGAY_SINH".into()),
+ Data::String("SBD".into()),
+ ];
+ let cfg = hdr(&["HO_TEN", "HỌ TÊN", "STT"]);
+ assert!(is_header_row(&row, &cfg));
+ }
+
+ #[test]
+ fn header_detects_stt() {
+ let row = vec![
+ Data::String("STT".into()),
+ Data::String("B".into()),
+ Data::String("C".into()),
+ ];
+ let cfg = hdr(&["HO_TEN", "HỌ TÊN", "STT"]);
+ assert!(is_header_row(&row, &cfg));
+ }
+
+ #[test]
+ fn header_detects_ho_ten_unicode() {
+ let row = vec![
+ Data::String("HỌ TÊN".into()),
+ Data::String("B".into()),
+ Data::String("C".into()),
+ ];
+ let cfg = hdr(&["HO_TEN", "HỌ TÊN", "STT"]);
+ assert!(is_header_row(&row, &cfg));
+ }
+
+ #[test]
+ fn header_rejects_data_row() {
+ let row = vec![
+ Data::String("Nguyen Van A".into()),
+ Data::String("01/01/2000".into()),
+ Data::String("12345678".into()),
+ ];
+ let cfg = hdr(&["HO_TEN", "HỌ TÊN", "STT"]);
+ assert!(!is_header_row(&row, &cfg));
+ }
+
+ #[test]
+ fn header_rejects_short_row() {
+ let row = vec![Data::String("HO_TEN".into()), Data::Empty];
+ let cfg = hdr(&["HO_TEN", "HỌ TÊN", "STT"]);
+ assert!(!is_header_row(&row, &cfg));
+ }
+
+ #[test]
+ fn header_case_insensitive() {
+ let row = vec![
+ Data::String("ho_ten".into()),
+ Data::String("B".into()),
+ Data::String("C".into()),
+ ];
+ let cfg = hdr(&["HO_TEN", "HỌ TÊN", "STT"]);
+ assert!(is_header_row(&row, &cfg));
+ }
+
+ #[test]
+ fn blank_row_detection() {
+ let row = vec![Data::Empty, Data::Empty, Data::String("".into())];
+ assert!(is_all_blank(&row));
+
+ let row2 = vec![Data::String("Nguyen".into()), Data::Empty, Data::Empty];
+ assert!(!is_all_blank(&row2));
+ }
+}
diff --git a/2017/tools/xlsxread/src/transform.rs b/2017/tools/xlsxread/src/transform.rs
new file mode 100644
index 0000000..bff5c21
--- /dev/null
+++ b/2017/tools/xlsxread/src/transform.rs
@@ -0,0 +1,398 @@
+/// Row transformation: ascii normalisation, score regex parsing, validation.
+///
+/// `to_ascii` replicates build-lib.js `toAscii` exactly:
+/// str.normalize("NFD").replace(/[̀-ͯ]/g,"").replace(/đ/gi,"d").toLowerCase()
+use std::collections::HashMap;
+
+use regex::Regex;
+use unicode_normalization::UnicodeNormalization;
+
+use crate::config::{DatasetConfig, ValidationCfg};
+use crate::error::BuildError;
+
+// ---------------------------------------------------------------------------
+// Compiled score patterns (built once at startup from config)
+// ---------------------------------------------------------------------------
+
+pub struct CompiledPatterns {
+ /// Ordered list so INSERT column order is deterministic
+ pub patterns: Vec<(String, Regex)>,
+}
+
+impl CompiledPatterns {
+ pub fn new(scores: &HashMap) -> Result {
+ let mut patterns = Vec::with_capacity(scores.len());
+ for (field, src) in scores {
+ let re = Regex::new(src).map_err(|e| BuildError::Regex {
+ pattern: src.clone(),
+ source: e,
+ })?;
+ patterns.push((field.clone(), re));
+ }
+ // Sort for deterministic order across HashMap iteration
+ patterns.sort_by(|a, b| a.0.cmp(&b.0));
+ Ok(Self { patterns })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// to_ascii — must be byte-for-byte equivalent to build-lib.js toAscii
+// ---------------------------------------------------------------------------
+
+/// Normalise a Vietnamese name to an ASCII slug.
+///
+/// Algorithm mirrors the JavaScript `toAscii` in build-lib.js:
+/// 1. NFD decompose (splits base + combining diacritics)
+/// 2. Drop all Unicode combining marks (U+0300–U+036F)
+/// 3. Replace đ/Đ with d (NFD does not decompose đ)
+/// 4. Lowercase
+pub fn to_ascii(s: &str) -> String {
+ // Step 1 + 2: NFD then filter out combining marks (Unicode category M)
+ let decomposed: String = s
+ .nfd()
+ .filter(|c| !('\u{0300}'..='\u{036f}').contains(c))
+ .collect();
+
+ // Step 3: đ/Đ are not decomposed by NFD — replace explicitly
+ let replaced = decomposed.replace(['đ', 'Đ'], "d");
+
+ // Step 4: lowercase
+ replaced.to_lowercase()
+}
+
+// ---------------------------------------------------------------------------
+// Parsed row ready for DB insert
+// ---------------------------------------------------------------------------
+
+pub struct ParsedRow {
+ pub so_bao_danh: String,
+ pub ho_ten: String,
+ pub ho_ten_ascii: String,
+ pub ngay_sinh: Option,
+ /// Subject field → float value; absent subjects not in map → NULL
+ pub scores: HashMap,
+}
+
+// ---------------------------------------------------------------------------
+// Row validation — mirrors the per-script skip logic
+// ---------------------------------------------------------------------------
+
+/// Returns `None` when the row should be skipped entirely (before sourceRows counter).
+/// Returns `Some(reason)` when the row should be counted as sourceRows but skipped.
+#[derive(Debug, PartialEq, Eq)]
+pub enum SkipReason {
+ /// Row is fully blank (data-old2 only, before sourceRows counter)
+ BlankRow,
+ /// soBaoDanh or hoTen empty/missing
+ EmptyField,
+ /// soBaoDanh contains non-digit characters (data-old / data-old2 guard)
+ NonNumericSbd,
+}
+
+/// Validates a raw cell slice against the dataset's `ValidationCfg`.
+/// Returns `Ok(())` on pass, `Err(SkipReason)` on fail.
+pub fn validate_row(
+ ho_ten: &str,
+ so_bao_danh: &str,
+ cfg: &ValidationCfg,
+ strip_blank_rows: bool,
+ all_blank: bool,
+) -> Result<(), SkipReason> {
+ // data-old2: skip fully blank rows BEFORE counting sourceRows
+ if strip_blank_rows && all_blank {
+ return Err(SkipReason::BlankRow);
+ }
+
+ if cfg.require_nonempty_sbd && so_bao_danh.is_empty() {
+ return Err(SkipReason::EmptyField);
+ }
+ if cfg.require_nonempty_name && ho_ten.is_empty() {
+ return Err(SkipReason::EmptyField);
+ }
+ if cfg.require_numeric_sbd && !so_bao_danh.chars().all(|c| c.is_ascii_digit()) {
+ return Err(SkipReason::NonNumericSbd);
+ }
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Score parsing — mirrors build-lib.js parseScores
+// ---------------------------------------------------------------------------
+
+/// Parse a DIEM_THI cell string and extract matching subject scores.
+pub fn parse_scores(diem_thi: &str, patterns: &CompiledPatterns) -> HashMap {
+ let mut out = HashMap::new();
+ for (field, re) in &patterns.patterns {
+ if let Some(caps) = re.captures(diem_thi) {
+ if let Some(m) = caps.get(1) {
+ if let Ok(v) = m.as_str().parse::() {
+ if v.is_finite() {
+ out.insert(field.clone(), v);
+ }
+ }
+ }
+ }
+ }
+ out
+}
+
+// ---------------------------------------------------------------------------
+// Full row transform
+// ---------------------------------------------------------------------------
+
+/// Extract and transform one spreadsheet row into a `ParsedRow`.
+/// `raw` is the full cell slice; column indices come from `cfg.columns`.
+pub fn transform_row(
+ raw: &[calamine::Data],
+ cfg: &DatasetConfig,
+ patterns: &CompiledPatterns,
+) -> ParsedRow {
+ let get = |idx: usize| -> String {
+ raw.get(idx)
+ .map(|cell| cell.to_string().trim().to_owned())
+ .unwrap_or_default()
+ };
+
+ let ho_ten = get(cfg.columns.ho_ten);
+ let ngay_sinh = get(cfg.columns.ngay_sinh);
+ let so_bao_danh = get(cfg.columns.so_bao_danh);
+ let diem_thi = raw
+ .get(cfg.columns.diem_thi)
+ .map(|c| c.to_string())
+ .unwrap_or_default();
+
+ let ho_ten_ascii = to_ascii(&ho_ten);
+ let scores = parse_scores(&diem_thi, patterns);
+ let ngay_sinh_opt = if ngay_sinh.is_empty() {
+ None
+ } else {
+ Some(ngay_sinh)
+ };
+
+ ParsedRow {
+ so_bao_danh,
+ ho_ten,
+ ho_ten_ascii,
+ ngay_sinh: ngay_sinh_opt,
+ scores,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Unit tests — 20 cases for to_ascii (real Vietnamese names)
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // Helper: assert to_ascii(input) == expected
+ fn check(input: &str, expected: &str) {
+ assert_eq!(
+ to_ascii(input),
+ expected,
+ "to_ascii({input:?}) expected {expected:?}"
+ );
+ }
+
+ #[test]
+ fn ascii_plain_latin() {
+ check("Nguyen Van A", "nguyen van a");
+ }
+
+ #[test]
+ fn ascii_nguyen_thi_hoa() {
+ check("Nguyễn Thị Hoa", "nguyen thi hoa");
+ }
+
+ #[test]
+ fn ascii_tran_van_duc() {
+ // đ/Đ replacement
+ check("Trần Văn Đức", "tran van duc");
+ }
+
+ #[test]
+ fn ascii_le_thi_my_duyen() {
+ check("Lê Thị Mỹ Duyên", "le thi my duyen");
+ }
+
+ #[test]
+ fn ascii_pham_thi_lan() {
+ check("Phạm Thị Lan", "pham thi lan");
+ }
+
+ #[test]
+ fn ascii_bui_thi_thu() {
+ check("Bùi Thị Thu", "bui thi thu");
+ }
+
+ #[test]
+ fn ascii_hoang_van_truong() {
+ check("Hoàng Văn Trường", "hoang van truong");
+ }
+
+ #[test]
+ fn ascii_do_thi_ngan() {
+ // Đ uppercase at start
+ check("Đỗ Thị Ngân", "do thi ngan");
+ }
+
+ #[test]
+ fn ascii_nguyen_van_khanh() {
+ check("Nguyễn Văn Khánh", "nguyen van khanh");
+ }
+
+ #[test]
+ fn ascii_trinh_thi_bich_ngoc() {
+ check("Trịnh Thị Bích Ngọc", "trinh thi bich ngoc");
+ }
+
+ #[test]
+ fn ascii_vu_thi_dieu() {
+ // ề = e + combining grave + combining circumflex (after NFD)
+ check("Vũ Thị Diệu", "vu thi dieu");
+ }
+
+ #[test]
+ fn ascii_nguyen_thi_tuong_vi() {
+ check("Nguyễn Thị Tường Vi", "nguyen thi tuong vi");
+ }
+
+ #[test]
+ fn ascii_lowercase_d_stroke() {
+ // Lowercase đ → d
+ check("đặng thị hằng", "dang thi hang");
+ }
+
+ #[test]
+ fn ascii_uppercase_d_stroke() {
+ check("ĐẶNG THỊ HẰNG", "dang thi hang");
+ }
+
+ #[test]
+ fn ascii_mixed_case() {
+ check("NGUYỄN VĂN AN", "nguyen van an");
+ }
+
+ #[test]
+ fn ascii_tran_thi_kim_anh() {
+ check("Trần Thị Kim Anh", "tran thi kim anh");
+ }
+
+ #[test]
+ fn ascii_nguyen_thi_phuong_thao() {
+ check("Nguyễn Thị Phương Thảo", "nguyen thi phuong thao");
+ }
+
+ #[test]
+ fn ascii_le_van_long() {
+ check("Lê Văn Long", "le van long");
+ }
+
+ #[test]
+ fn ascii_vo_thi_xuan_mai() {
+ check("Võ Thị Xuân Mai", "vo thi xuan mai");
+ }
+
+ #[test]
+ fn ascii_empty_string() {
+ check("", "");
+ }
+
+ // --- Score parsing tests ---
+
+ fn make_patterns() -> CompiledPatterns {
+ let mut map = HashMap::new();
+ map.insert("toan".into(), r"Toán:\s*(\d+(?:\.\d+)?)".into());
+ map.insert("ngu_van".into(), r"Ngữ văn:\s*(\d+(?:\.\d+)?)".into());
+ map.insert("vat_ly".into(), r"Vật lí:\s*(\d+(?:\.\d+)?)".into());
+ CompiledPatterns::new(&map).unwrap()
+ }
+
+ #[test]
+ fn parse_scores_single() {
+ let p = make_patterns();
+ let s = "Toán: 8.5";
+ let scores = parse_scores(s, &p);
+ assert_eq!(scores.get("toan"), Some(&8.5));
+ assert!(scores.get("ngu_van").is_none());
+ }
+
+ #[test]
+ fn parse_scores_multiple() {
+ let p = make_patterns();
+ let s = "Toán: 7.25 Ngữ văn: 6.0 Vật lí: 9";
+ let scores = parse_scores(s, &p);
+ assert_eq!(scores.get("toan"), Some(&7.25));
+ assert_eq!(scores.get("ngu_van"), Some(&6.0));
+ assert_eq!(scores.get("vat_ly"), Some(&9.0));
+ }
+
+ #[test]
+ fn parse_scores_empty_cell() {
+ let p = make_patterns();
+ let scores = parse_scores("", &p);
+ assert!(scores.is_empty());
+ }
+
+ // --- Validation tests ---
+
+ fn default_validation() -> ValidationCfg {
+ ValidationCfg {
+ require_numeric_sbd: false,
+ require_nonempty_name: true,
+ require_nonempty_sbd: true,
+ }
+ }
+
+ #[test]
+ fn validate_ok() {
+ let v = default_validation();
+ assert!(validate_row("Nguyen Van A", "12345678", &v, false, false).is_ok());
+ }
+
+ #[test]
+ fn validate_empty_sbd() {
+ let v = default_validation();
+ assert_eq!(
+ validate_row("Nguyen Van A", "", &v, false, false),
+ Err(SkipReason::EmptyField)
+ );
+ }
+
+ #[test]
+ fn validate_empty_name() {
+ let v = default_validation();
+ assert_eq!(
+ validate_row("", "12345678", &v, false, false),
+ Err(SkipReason::EmptyField)
+ );
+ }
+
+ #[test]
+ fn validate_non_numeric_sbd_rejected() {
+ let mut v = default_validation();
+ v.require_numeric_sbd = true;
+ assert_eq!(
+ validate_row("Nguyen Van A", "12AB5678", &v, false, false),
+ Err(SkipReason::NonNumericSbd)
+ );
+ }
+
+ #[test]
+ fn validate_numeric_sbd_accepted() {
+ let mut v = default_validation();
+ v.require_numeric_sbd = true;
+ assert!(validate_row("Nguyen Van A", "12345678", &v, false, false).is_ok());
+ }
+
+ #[test]
+ fn validate_blank_row_skipped() {
+ let v = default_validation();
+ // strip_blank_rows=true AND all_blank=true → BlankRow
+ assert_eq!(
+ validate_row("", "", &v, true, true),
+ Err(SkipReason::BlankRow)
+ );
+ }
+}
diff --git a/parser/src/writer.rs b/2017/tools/xlsxread/src/writer.rs
similarity index 67%
rename from parser/src/writer.rs
rename to 2017/tools/xlsxread/src/writer.rs
index e8fc17a..c75c4ea 100644
--- a/parser/src/writer.rs
+++ b/2017/tools/xlsxread/src/writer.rs
@@ -2,25 +2,22 @@
///
/// Mirrors build-lib.js createDb + the transaction loop in each build-database*.js.
/// Stats output lines match the JS stdout exactly so existing CI log-greps still work.
-///
-/// Every dataset writes the same canonical table (see `crate::schema`), so there
-/// is exactly one insert path. Columns a dataset carries no data for bind NULL.
use std::fs;
use std::path::Path;
use rusqlite::{params_from_iter, Connection, ToSql};
+use crate::config::DatasetConfig;
use crate::error::BuildError;
-use crate::schema;
use crate::transform::ParsedRow;
// ---------------------------------------------------------------------------
// DB initialisation — mirrors build-lib.js createDb (delete + recreate)
// ---------------------------------------------------------------------------
-/// Open (or recreate) the output SQLite database, execute the canonical DDL,
+/// Open (or recreate) the output SQLite database, execute the DDL from config,
/// and return the open connection ready for inserts.
-pub fn open_db(db_path: &Path) -> Result {
+pub fn open_db(db_path: &Path, cfg: &DatasetConfig) -> Result {
// Mirror Node behaviour: delete existing file before creating (build-lib.js:54)
if db_path.exists() {
fs::remove_file(db_path).map_err(|e| BuildError::Io {
@@ -40,40 +37,58 @@ pub fn open_db(db_path: &Path) -> Result {
}
let conn = Connection::open(db_path)?;
- conn.execute_batch(schema::DDL)?;
+ conn.execute_batch(&cfg.schema.ddl)?;
Ok(conn)
}
// ---------------------------------------------------------------------------
-// Insert a single parsed row
+// Ordered score field list — canonical INSERT column order from build-lib.js
// ---------------------------------------------------------------------------
-/// Bind every field from `row` into the canonical INSERT and execute it.
-///
-/// Parameter order is `schema::IDENTITY_FIELDS` followed by
-/// `schema::SCORE_FIELDS`. Subjects absent from `row.scores` — and the two
-/// identity columns only the thptqg2016 layouts populate — bind NULL.
-pub fn insert_row(conn: &Connection, row: &ParsedRow) -> Result<(), BuildError> {
- let mut params: Vec> = Vec::with_capacity(schema::PARAM_COUNT);
+/// Fixed subject column order matching the INSERT statement in every config.
+/// NULL is bound for any subject not present in a given row's score map.
+pub const SCORE_FIELDS: &[&str] = &[
+ "toan",
+ "ngu_van",
+ "vat_ly",
+ "hoa_hoc",
+ "sinh_hoc",
+ "khtn",
+ "lich_su",
+ "dia_ly",
+ "gdcd",
+ "khxh",
+ "tieng_anh",
+ "tieng_phap",
+ "tieng_nga",
+ "tieng_trung",
+];
+// ---------------------------------------------------------------------------
+// Insert a single parsed row inside an active transaction
+// ---------------------------------------------------------------------------
+
+/// Bind all fields from `row` into the prepared statement and execute it.
+/// `score_fields` should be the ordered list of subject columns the INSERT expects.
+pub fn insert_row(
+ conn: &Connection,
+ sql: &str,
+ row: &ParsedRow,
+ score_fields: &[&str],
+) -> Result<(), BuildError> {
+ // Build positional params: so_bao_danh, ho_ten, ho_ten_ascii, ngay_sinh,
+ let mut params: Vec> = Vec::with_capacity(4 + score_fields.len());
params.push(Box::new(row.so_bao_danh.clone()));
params.push(Box::new(row.ho_ten.clone()));
params.push(Box::new(row.ho_ten_ascii.clone()));
params.push(Box::new(row.ngay_sinh.clone()));
- params.push(Box::new(row.ten_cum_thi.clone()));
- params.push(Box::new(row.gioi_tinh.clone()));
- for field in schema::SCORE_FIELDS {
+ for field in score_fields {
let val: Option = row.scores.get(*field).copied();
params.push(Box::new(val));
}
- debug_assert_eq!(params.len(), schema::PARAM_COUNT);
-
- conn.execute(
- schema::INSERT_SQL,
- params_from_iter(params.iter().map(|p| p.as_ref())),
- )?;
+ conn.execute(sql, params_from_iter(params.iter().map(|p| p.as_ref())))?;
Ok(())
}
@@ -91,9 +106,9 @@ pub fn finish_db(
source_rows: u64,
skipped: u64,
errors: u64,
- dataset_label: &str,
+ dataset_label: &str, // e.g. "data/" or "data-old2/"
_file_count: usize,
- is_old2: bool,
+ is_old2: bool, // data-old2 uses different label for the skipped line
) -> Result<(), BuildError> {
conn.execute_batch("VACUUM")?;
@@ -101,6 +116,7 @@ pub fn finish_db(
let insertable = source_rows - skipped;
+ // Mirror exact JS stdout format for each dataset variant
println!();
if is_old2 {
println!("Source non-blank data rows: {source_rows}");
@@ -117,6 +133,7 @@ pub fn finish_db(
println!(" insert errors: {errors}");
println!("DB rows (distinct SBD): {db_count}");
+ // Audit gap comment (mirrors build-database.js:80-83 for data/ only)
if !dataset_label.contains("old") && errors == 0 {
let gap = insertable as i64 - db_count;
if gap == 0 {
diff --git a/2017/tools/xlsxread/tests/fixtures/README.md b/2017/tools/xlsxread/tests/fixtures/README.md
new file mode 100644
index 0000000..c0885f3
--- /dev/null
+++ b/2017/tools/xlsxread/tests/fixtures/README.md
@@ -0,0 +1,18 @@
+# Test Fixtures
+
+Anonymised `.xlsx` files for integration testing. All student PII has been replaced:
+
+- `ho_ten` replaced with `Nguyen Van Test NNN` / `Tran Thi Test NNN` patterns
+- `so_bao_danh` replaced with sequential synthetic numbers (e.g. `10000001`)
+- `ngay_sinh` replaced with fixed synthetic dates
+- Scores are realistic random values in the 0–10 range
+
+Files:
+
+- `province-100.xlsx` — 100-row single-sheet file (simulates a normal province)
+- `hcm-overflow.xlsx` — 2-sheet file (200 rows Sheet1 + 200 rows Sheet2, simulating HCM overflow)
+- `province-numeric-sbd.xlsx` — 100 rows with strictly numeric SBDs (for data-old variant)
+
+These files are generated by `tests/golden.rs` `generate_fixtures()` if they do not already exist
+on disk. The generator is pure Rust (uses the `zip` crate already pulled in via calamine).
+No external Python or Node tooling required for unit/integration tests.
diff --git a/2017/tools/xlsxread/tests/fixtures/hcm-overflow.xlsx b/2017/tools/xlsxread/tests/fixtures/hcm-overflow.xlsx
new file mode 100644
index 0000000..c92e498
Binary files /dev/null and b/2017/tools/xlsxread/tests/fixtures/hcm-overflow.xlsx differ
diff --git a/2017/tools/xlsxread/tests/fixtures/province-100.xlsx b/2017/tools/xlsxread/tests/fixtures/province-100.xlsx
new file mode 100644
index 0000000..5d1a109
Binary files /dev/null and b/2017/tools/xlsxread/tests/fixtures/province-100.xlsx differ
diff --git a/2017/tools/xlsxread/tests/fixtures/province-numeric-sbd.xlsx b/2017/tools/xlsxread/tests/fixtures/province-numeric-sbd.xlsx
new file mode 100644
index 0000000..5eb518c
Binary files /dev/null and b/2017/tools/xlsxread/tests/fixtures/province-numeric-sbd.xlsx differ
diff --git a/2017/tools/xlsxread/tests/golden.rs b/2017/tools/xlsxread/tests/golden.rs
new file mode 100644
index 0000000..fc2128a
--- /dev/null
+++ b/2017/tools/xlsxread/tests/golden.rs
@@ -0,0 +1,690 @@
+/// Stage 5 golden tests — integration tests using anonymised fixture files.
+///
+/// Fixture files are generated in-process via raw OOXML + zip if they do not
+/// already exist on disk. No external Python or Node tooling required for the
+/// Rust-side tests. The Node golden comparison is marked #[ignore] when pnpm
+/// is not in PATH.
+use std::io::Write as IoWrite;
+use std::path::{Path, PathBuf};
+
+// ---------------------------------------------------------------------------
+// Minimal OOXML xlsx generator
+//
+// Produces a valid .xlsx that calamine can read. Only uses the `zip` crate
+// which is already pulled in as a transitive dependency of calamine.
+// ---------------------------------------------------------------------------
+
+/// One row of cell data for a fixture sheet.
+struct XlsxRow {
+ values: Vec,
+}
+
+/// Write a minimal .xlsx to `path` with the given sheets.
+/// `sheets`: Vec<(sheet_name, rows)> where rows[0] is the header.
+fn write_xlsx(path: &Path, sheets: &[(String, Vec)]) {
+ use zip::{write::SimpleFileOptions, ZipWriter};
+
+ let file = std::fs::File::create(path).expect("create fixture xlsx");
+ let mut zip = ZipWriter::new(file);
+ let opts = SimpleFileOptions::default();
+
+ // [Content_Types].xml
+ let mut content_types = String::from(
+ r#"
+
+
+
+
+"#,
+ );
+ for (i, _) in sheets.iter().enumerate() {
+ content_types.push_str(&format!(
+ r#"
+"#,
+ i + 1
+ ));
+ }
+ content_types.push_str(" ");
+ zip.start_file("[Content_Types].xml", opts).unwrap();
+ zip.write_all(content_types.as_bytes()).unwrap();
+
+ // _rels/.rels
+ zip.start_file("_rels/.rels", opts).unwrap();
+ zip.write_all(
+ br#"
+
+
+ "#,
+ )
+ .unwrap();
+
+ // xl/_rels/workbook.xml.rels
+ let mut wb_rels = String::from(
+ r#"
+
+"#,
+ );
+ for (i, _) in sheets.iter().enumerate() {
+ wb_rels.push_str(&format!(
+ r#"
+"#,
+ i + 1,
+ i + 1
+ ));
+ }
+ wb_rels.push_str(" ");
+ zip.start_file("xl/_rels/workbook.xml.rels", opts).unwrap();
+ zip.write_all(wb_rels.as_bytes()).unwrap();
+
+ // xl/workbook.xml
+ let mut wb = String::from(
+ r#"
+
+
+"#,
+ );
+ for (i, (name, _)) in sheets.iter().enumerate() {
+ let escaped = xml_escape(name);
+ wb.push_str(&format!(
+ r#"
+"#,
+ escaped,
+ i + 1,
+ i + 1
+ ));
+ }
+ wb.push_str(" \n ");
+ zip.start_file("xl/workbook.xml", opts).unwrap();
+ zip.write_all(wb.as_bytes()).unwrap();
+
+ // xl/worksheets/sheetN.xml
+ for (i, (_, rows)) in sheets.iter().enumerate() {
+ let mut ws = String::from(
+ r#"
+
+
+"#,
+ );
+ for (row_idx, row) in rows.iter().enumerate() {
+ ws.push_str(&format!(
+ r#"
+"#,
+ row_idx + 1
+ ));
+ for (col_idx, val) in row.values.iter().enumerate() {
+ let col_letter = col_letter(col_idx);
+ let cell_ref = format!("{}{}", col_letter, row_idx + 1);
+ let escaped = xml_escape(val);
+ ws.push_str(&format!(
+ r#" {}
+"#,
+ cell_ref, escaped
+ ));
+ }
+ ws.push_str("
\n");
+ }
+ ws.push_str(" \n ");
+ zip.start_file(&format!("xl/worksheets/sheet{}.xml", i + 1), opts)
+ .unwrap();
+ zip.write_all(ws.as_bytes()).unwrap();
+ }
+
+ zip.finish().unwrap();
+}
+
+fn col_letter(idx: usize) -> &'static str {
+ const LETTERS: &[&str] = &[
+ "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R",
+ "S", "T", "U", "V", "W", "X", "Y", "Z",
+ ];
+ LETTERS[idx % 26]
+}
+
+fn xml_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+
+// ---------------------------------------------------------------------------
+// Fixture data builders
+// ---------------------------------------------------------------------------
+
+fn header_row() -> XlsxRow {
+ XlsxRow {
+ values: vec![
+ "HO_TEN".into(),
+ "NGAY_SINH".into(),
+ "SO_BAO_DANH".into(),
+ "DIEM_THI".into(),
+ ],
+ }
+}
+
+fn data_row(idx: usize, scores: &str) -> XlsxRow {
+ // Anonymised: name uses sequential pattern, SBD is purely synthetic
+ let name = if idx % 2 == 0 {
+ format!("Nguyen Van Test {:03}", idx)
+ } else {
+ format!("Tran Thi Test {:03}", idx)
+ };
+ XlsxRow {
+ values: vec![
+ name,
+ format!("15/0{}/{}", (idx % 9) + 1, 1999 + (idx % 5)),
+ format!("1000{:04}", idx),
+ scores.to_owned(),
+ ],
+ }
+}
+
+fn sample_scores(idx: usize) -> String {
+ // Realistic scores in 0–10 range, varies by idx
+ let toan = 4.0 + (idx % 60) as f64 / 10.0;
+ let van = 3.5 + (idx % 65) as f64 / 10.0;
+ format!("Toán: {toan:.1} Ngữ văn: {van:.1} Tiếng Anh: 7.5")
+}
+
+// ---------------------------------------------------------------------------
+// Fixture file paths
+// ---------------------------------------------------------------------------
+
+fn fixtures_dir() -> PathBuf {
+ // tests/fixtures/ relative to the crate root
+ let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ p.push("tests");
+ p.push("fixtures");
+ p
+}
+
+fn province_fixture_path() -> PathBuf {
+ fixtures_dir().join("province-100.xlsx")
+}
+
+fn hcm_overflow_fixture_path() -> PathBuf {
+ fixtures_dir().join("hcm-overflow.xlsx")
+}
+
+fn numeric_sbd_fixture_path() -> PathBuf {
+ fixtures_dir().join("province-numeric-sbd.xlsx")
+}
+
+// ---------------------------------------------------------------------------
+// Fixture generation — called once per test run if files missing
+// ---------------------------------------------------------------------------
+
+fn ensure_fixtures() {
+ let dir = fixtures_dir();
+ std::fs::create_dir_all(&dir).expect("create fixtures dir");
+
+ // province-100.xlsx — 100 data rows, single sheet, with header
+ if !province_fixture_path().exists() {
+ let mut rows = vec![header_row()];
+ for i in 0..100 {
+ rows.push(data_row(i, &sample_scores(i)));
+ }
+ write_xlsx(&province_fixture_path(), &[("Sheet1".to_owned(), rows)]);
+ }
+
+ // hcm-overflow.xlsx — 2 sheets × 200 rows each (no header on sheet 2)
+ if !hcm_overflow_fixture_path().exists() {
+ let mut sheet1 = vec![header_row()];
+ for i in 0..200 {
+ sheet1.push(data_row(i, &sample_scores(i)));
+ }
+ // Sheet2: continuation rows, no header row (as in real HCM overflow)
+ let mut sheet2 = Vec::new();
+ for i in 200..400 {
+ sheet2.push(data_row(i, &sample_scores(i)));
+ }
+ write_xlsx(
+ &hcm_overflow_fixture_path(),
+ &[("Sheet1".to_owned(), sheet1), ("Sheet2".to_owned(), sheet2)],
+ );
+ }
+
+ // province-numeric-sbd.xlsx — strictly numeric SBDs for data-old config
+ if !numeric_sbd_fixture_path().exists() {
+ let mut rows = vec![header_row()];
+ for i in 0..100 {
+ rows.push(XlsxRow {
+ values: vec![
+ format!("Nguyen Van Test {:03}", i),
+ "01/01/2000".to_owned(),
+ format!("{:08}", 20000000 + i), // pure digits
+ sample_scores(i),
+ ],
+ });
+ }
+ write_xlsx(&numeric_sbd_fixture_path(), &[("Sheet1".to_owned(), rows)]);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Config helpers
+// ---------------------------------------------------------------------------
+
+fn make_data_config() -> xlsxread::config::DatasetConfig {
+ let cfg_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("configs")
+ .join("thptqg2017-data.toml");
+ xlsxread::config::load_config(&cfg_path).expect("load data config")
+}
+
+// ---------------------------------------------------------------------------
+// Integration tests — pure Rust, no Node dependency
+// ---------------------------------------------------------------------------
+
+#[test]
+fn province_100_builds_100_rows() {
+ ensure_fixtures();
+ let dir = tempdir();
+ let db_path = dir.join("test.db");
+ let fixture_dir = dir.join("input");
+ std::fs::create_dir_all(&fixture_dir).unwrap();
+ std::fs::copy(province_fixture_path(), fixture_dir.join("province.xlsx")).unwrap();
+
+ run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data.toml");
+
+ let count = query_count(&db_path);
+ assert_eq!(count, 100, "expected 100 rows from province-100 fixture");
+}
+
+#[test]
+fn hcm_overflow_builds_400_rows() {
+ ensure_fixtures();
+ let dir = tempdir();
+ let db_path = dir.join("test.db");
+ let fixture_dir = dir.join("input");
+ std::fs::create_dir_all(&fixture_dir).unwrap();
+ std::fs::copy(hcm_overflow_fixture_path(), fixture_dir.join("hcm.xlsx")).unwrap();
+
+ run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data.toml");
+
+ let count = query_count(&db_path);
+ assert_eq!(
+ count, 400,
+ "expected 400 rows (200 × 2 sheets) from hcm-overflow fixture"
+ );
+}
+
+#[test]
+fn data_old_first_sheet_only_100_rows() {
+ ensure_fixtures();
+ let dir = tempdir();
+ let db_path = dir.join("test.db");
+ let fixture_dir = dir.join("input");
+ std::fs::create_dir_all(&fixture_dir).unwrap();
+ // Use the overflow file but with data-old config (first sheet only → 200 rows)
+ std::fs::copy(hcm_overflow_fixture_path(), fixture_dir.join("hcm.xlsx")).unwrap();
+
+ run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data-old.toml");
+
+ // data-old: sheet_mode=first → only 200 rows from sheet1; but SBDs "1000NNNN" are
+ // all digits so all pass the numeric guard
+ let count = query_count(&db_path);
+ assert_eq!(
+ count, 200,
+ "data-old config should read only first sheet (200 rows)"
+ );
+}
+
+#[test]
+fn numeric_sbd_guard_rejects_non_numeric() {
+ ensure_fixtures();
+ let dir = tempdir();
+ let db_path = dir.join("test.db");
+ let fixture_dir = dir.join("input");
+ std::fs::create_dir_all(&fixture_dir).unwrap();
+
+ // Write a fixture with one non-numeric SBD mixed in
+ let mut rows = vec![header_row()];
+ for i in 0..10 {
+ rows.push(XlsxRow {
+ values: vec![
+ format!("Test {:03}", i),
+ "01/01/2000".to_owned(),
+ if i == 5 {
+ "ABC123".to_owned()
+ } else {
+ format!("{:08}", 20000000 + i)
+ },
+ sample_scores(i),
+ ],
+ });
+ }
+ let mixed_path = fixture_dir.join("mixed.xlsx");
+ write_xlsx(&mixed_path, &[("Sheet1".to_owned(), rows)]);
+
+ run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data-old.toml");
+
+ // Row i=5 has non-numeric SBD → rejected by data-old config
+ let count = query_count(&db_path);
+ assert_eq!(
+ count, 9,
+ "non-numeric SBD row should be skipped by data-old config"
+ );
+}
+
+#[test]
+fn scores_parsed_correctly_into_db() {
+ ensure_fixtures();
+ let dir = tempdir();
+ let db_path = dir.join("test.db");
+ let fixture_dir = dir.join("input");
+ std::fs::create_dir_all(&fixture_dir).unwrap();
+
+ let rows = vec![
+ header_row(),
+ XlsxRow {
+ values: vec![
+ "Nguyen Van Test 001".to_owned(),
+ "01/01/2000".to_owned(),
+ "10000001".to_owned(),
+ "Toán: 8.5 Ngữ văn: 7.0 Tiếng Anh: 9.25".to_owned(),
+ ],
+ },
+ ];
+ write_xlsx(
+ &fixture_dir.join("one.xlsx"),
+ &[("Sheet1".to_owned(), rows)],
+ );
+
+ run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data.toml");
+
+ let conn = rusqlite::Connection::open(&db_path).unwrap();
+ let (toan, van, anh): (f64, f64, f64) = conn
+ .query_row(
+ "SELECT toan, ngu_van, tieng_anh FROM student WHERE so_bao_danh = '10000001'",
+ [],
+ |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
+ )
+ .expect("row not found");
+ assert!((toan - 8.5).abs() < 1e-9);
+ assert!((van - 7.0).abs() < 1e-9);
+ assert!((anh - 9.25).abs() < 1e-9);
+}
+
+#[test]
+fn to_ascii_stored_correctly() {
+ ensure_fixtures();
+ let dir = tempdir();
+ let db_path = dir.join("test.db");
+ let fixture_dir = dir.join("input");
+ std::fs::create_dir_all(&fixture_dir).unwrap();
+
+ let rows = vec![
+ header_row(),
+ XlsxRow {
+ values: vec![
+ "Nguyễn Văn Đức".to_owned(),
+ "".to_owned(),
+ "20000001".to_owned(),
+ "".to_owned(),
+ ],
+ },
+ ];
+ write_xlsx(
+ &fixture_dir.join("one.xlsx"),
+ &[("Sheet1".to_owned(), rows)],
+ );
+
+ run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data.toml");
+
+ let conn = rusqlite::Connection::open(&db_path).unwrap();
+ let ascii: String = conn
+ .query_row(
+ "SELECT ho_ten_ascii FROM student WHERE so_bao_danh = '20000001'",
+ [],
+ |r| r.get(0),
+ )
+ .expect("row not found");
+ assert_eq!(ascii, "nguyen van duc");
+}
+
+#[test]
+fn audit_subcommand_matches_after_build() {
+ ensure_fixtures();
+ let dir = tempdir();
+ let db_path = dir.join("test.db");
+ let fixture_dir = dir.join("input");
+ std::fs::create_dir_all(&fixture_dir).unwrap();
+ std::fs::copy(province_fixture_path(), fixture_dir.join("province.xlsx")).unwrap();
+
+ run_build_cmd(&fixture_dir, &db_path, "thptqg2017-data.toml");
+
+ // audit should match (100 distinct SBDs in xlsx == 100 rows in DB)
+ let cfg = make_data_config();
+ let result = xlsxread::audit::run_audit(&fixture_dir, &db_path, &cfg).expect("audit failed");
+ assert!(result.matched, "audit should match after build");
+ assert_eq!(result.distinct_sbds, 100);
+ assert_eq!(result.db_count, 100);
+}
+
+#[test]
+fn audit_subcommand_mismatch_detected() {
+ ensure_fixtures();
+ let dir = tempdir();
+ let db_path = dir.join("test.db");
+ let fixture_dir = dir.join("input");
+ std::fs::create_dir_all(&fixture_dir).unwrap();
+
+ // Write 10 rows to xlsx but build DB from only 5 rows
+ let mut all_rows = vec![header_row()];
+ for i in 0..10 {
+ all_rows.push(data_row(i, &sample_scores(i)));
+ }
+ write_xlsx(
+ &fixture_dir.join("all.xlsx"),
+ &[("Sheet1".to_owned(), all_rows)],
+ );
+
+ // Build DB with only first 5 rows in a different file
+ let build_dir = dir.join("build_input");
+ std::fs::create_dir_all(&build_dir).unwrap();
+ let mut five_rows = vec![header_row()];
+ for i in 0..5 {
+ five_rows.push(data_row(i, &sample_scores(i)));
+ }
+ write_xlsx(
+ &build_dir.join("five.xlsx"),
+ &[("Sheet1".to_owned(), five_rows)],
+ );
+
+ run_build_cmd(&build_dir, &db_path, "thptqg2017-data.toml");
+
+ // audit against fixture_dir (10 xlsx rows) but DB has 5 rows → mismatch
+ let cfg = make_data_config();
+ let result = xlsxread::audit::run_audit(&fixture_dir, &db_path, &cfg).expect("audit failed");
+ assert!(!result.matched, "audit should not match (10 xlsx vs 5 db)");
+ assert_eq!(result.distinct_sbds, 10);
+ assert_eq!(result.db_count, 5);
+}
+
+// ---------------------------------------------------------------------------
+// Golden test: compare Rust DB vs Node DB on identical fixture
+// Marked #[ignore] when pnpm / node is not in PATH — CI installs them first.
+// ---------------------------------------------------------------------------
+
+#[test]
+#[ignore]
+fn golden_rust_matches_node_db() {
+ // This test requires: pnpm, node, and the thptqg2017 package to be installed
+ // Run with: cargo test -- --ignored golden_rust_matches_node_db
+ let which_pnpm = std::process::Command::new("which")
+ .arg("pnpm")
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false);
+ if !which_pnpm {
+ eprintln!("pnpm not in PATH — skipping golden test");
+ return;
+ }
+
+ let dir = tempdir();
+ let fixture_dir = dir.join("input");
+ std::fs::create_dir_all(&fixture_dir).unwrap();
+ std::fs::copy(province_fixture_path(), fixture_dir.join("province.xlsx")).unwrap();
+
+ // Build with Rust
+ let rust_db = dir.join("rust.db");
+ run_build_cmd(&fixture_dir, &rust_db, "thptqg2017-data.toml");
+
+ // Build with Node (run build-database.js with DATA_DIR / DB_PATH overrides)
+ // Node script reads env-vars via a thin wrapper — see scripts/build-database.js
+ // For now: diff via SELECT * ORDER BY so_bao_danh
+ let node_db = dir.join("node.db");
+ let status = std::process::Command::new("pnpm")
+ .args(["exec", "node", "scripts/build-database.js"])
+ .env("OVERRIDE_SRC_DIR", fixture_dir.to_str().unwrap())
+ .env("OVERRIDE_DB_PATH", node_db.to_str().unwrap())
+ .current_dir(
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .unwrap()
+ .parent()
+ .unwrap(),
+ )
+ .status()
+ .expect("failed to run node build script");
+
+ if !status.success() {
+ panic!("Node build script failed with: {status}");
+ }
+
+ // Row-by-row comparison
+ diff_dbs(&rust_db, &node_db);
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+fn tempdir() -> PathBuf {
+ let base = std::env::temp_dir().join(format!(
+ "xlsxread-test-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .subsec_nanos()
+ ));
+ std::fs::create_dir_all(&base).unwrap();
+ base
+}
+
+fn run_build_cmd(input_dir: &Path, db_path: &Path, config_name: &str) {
+ let cfg_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("configs")
+ .join(config_name);
+
+ let cfg = xlsxread::config::load_config(&cfg_path)
+ .unwrap_or_else(|e| panic!("load config {config_name}: {e}"));
+ let patterns =
+ xlsxread::transform::CompiledPatterns::new(&cfg.scores).expect("compile patterns");
+
+ // Collect files
+ let mut files: Vec = std::fs::read_dir(input_dir)
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .map(|e| e.path())
+ .filter(|p| {
+ p.is_file()
+ && p.extension()
+ .and_then(|e| e.to_str())
+ .map(|e| {
+ let l = e.to_lowercase();
+ l == "xls" || l == "xlsx"
+ })
+ .unwrap_or(false)
+ })
+ .collect();
+ files.sort();
+
+ let conn = xlsxread::writer::open_db(db_path, &cfg).expect("open db");
+ conn.execute_batch("BEGIN").unwrap();
+
+ for file in &files {
+ xlsxread::reader::process_file(file, &cfg, |_, raw| {
+ let all_blank = xlsxread::reader::is_all_blank(raw);
+ if cfg.reader.strip_blank_rows && all_blank {
+ return;
+ }
+ let ho_ten = raw
+ .get(cfg.columns.ho_ten)
+ .map(|c| c.to_string().trim().to_owned())
+ .unwrap_or_default();
+ let so_bao_danh = raw
+ .get(cfg.columns.so_bao_danh)
+ .map(|c| c.to_string().trim().to_owned())
+ .unwrap_or_default();
+ if xlsxread::transform::validate_row(
+ &ho_ten,
+ &so_bao_danh,
+ &cfg.validation,
+ cfg.reader.strip_blank_rows,
+ all_blank,
+ )
+ .is_err()
+ {
+ return;
+ }
+ let row = xlsxread::transform::transform_row(raw, &cfg, &patterns);
+ let _ = xlsxread::writer::insert_row(
+ &conn,
+ &cfg.insert.sql,
+ &row,
+ xlsxread::writer::SCORE_FIELDS,
+ );
+ })
+ .expect("process file");
+ }
+
+ conn.execute_batch("COMMIT").unwrap();
+ conn.execute_batch("VACUUM").unwrap();
+}
+
+fn query_count(db_path: &Path) -> i64 {
+ let conn = rusqlite::Connection::open(db_path).expect("open db for count");
+ conn.query_row("SELECT COUNT(*) FROM student", [], |r| r.get(0))
+ .expect("count query")
+}
+
+fn diff_dbs(a: &Path, b: &Path) {
+ let conn_a = rusqlite::Connection::open(a).unwrap();
+
+ // Attach b as "other"
+ conn_a
+ .execute_batch(&format!("ATTACH DATABASE '{}' AS other", b.display()))
+ .unwrap();
+
+ // Rows in a not in b
+ let missing_in_b: i64 = conn_a
+ .query_row(
+ "SELECT COUNT(*) FROM main.student s
+ WHERE NOT EXISTS (SELECT 1 FROM other.student o WHERE o.so_bao_danh = s.so_bao_danh)",
+ [],
+ |r| r.get(0),
+ )
+ .unwrap();
+
+ // Rows in b not in a
+ let missing_in_a: i64 = conn_a
+ .query_row(
+ "SELECT COUNT(*) FROM other.student o
+ WHERE NOT EXISTS (SELECT 1 FROM main.student s WHERE s.so_bao_danh = o.so_bao_danh)",
+ [],
+ |r| r.get(0),
+ )
+ .unwrap();
+
+ assert_eq!(
+ missing_in_b, 0,
+ "{missing_in_b} rows in Rust DB missing from Node DB"
+ );
+ assert_eq!(
+ missing_in_a, 0,
+ "{missing_in_a} rows in Node DB missing from Rust DB"
+ );
+}
diff --git a/2017/vite.config.js b/2017/vite.config.js
new file mode 100644
index 0000000..87e0811
--- /dev/null
+++ b/2017/vite.config.js
@@ -0,0 +1,25 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// VARIANT selects which dataset to ship:
+// (unset) → main site at /thptqg2017/ using public/
+// old → at /thptqg2017/old/ using public-old/
+// old2 → at /thptqg2017/old2/ using public-old2/
+const VARIANT = process.env.VARIANT || "";
+const VARIANT_CONFIG = {
+ "": { base: "/thptqg/2017/", publicDir: "public", outDir: "dist" },
+ old: { base: "/thptqg/2017/old/", publicDir: "public-old", outDir: "dist/old" },
+ old2: { base: "/thptqg/2017/old2/", publicDir: "public-old2", outDir: "dist/old2" },
+};
+const cfg = VARIANT_CONFIG[VARIANT];
+if (!cfg) throw new Error(`Unknown VARIANT: ${VARIANT}`);
+
+export default defineConfig({
+ plugins: [react()],
+ base: cfg.base,
+ publicDir: cfg.publicDir,
+ build: {
+ outDir: cfg.outDir,
+ emptyOutDir: VARIANT === "", // only main build wipes dist root
+ },
+});
diff --git a/README.md b/README.md
index da6a3df..a50ca4a 100644
--- a/README.md
+++ b/README.md
@@ -2,67 +2,18 @@
Tra cứu điểm thi THPT Quốc gia — exam-score lookup for Vietnam's national high
school graduation exam. Client-side SQL (sql.js) over a SQLite database built
-from the ministry's raw `.xls` score files by the Rust `xlsxread` parser.
+from the ministry's raw `.xls` score files by the Rust `xlsxread` CLI.
-Live at **[tiennm99.github.io/thptqg](https://tiennm99.github.io/thptqg/)**.
+Served as one site at **[tiennm99.github.io/thptqg](https://tiennm99.github.io/thptqg/)**.
-| Dataset | Exam | Candidates | Site |
+| Directory | Year | Size | Site |
| --- | --- | --- | --- |
-| `2016` | 2016 | 877,461 | [/2016/](https://tiennm99.github.io/thptqg/2016/) |
-| `2017` | 2017 | 861,068 | [/2017/](https://tiennm99.github.io/thptqg/2017/) |
-| `2017-old` | 2017 | 847,348 | [/2017-old/](https://tiennm99.github.io/thptqg/2017-old/) |
-| `2017-old2` | 2017 | 679,764 | [/2017-old2/](https://tiennm99.github.io/thptqg/2017-old2/) |
+| [`2016/`](./2016/) | THPT QG 2016 — 877,461 candidates | ~50 MB raw data | [/2016/](https://tiennm99.github.io/thptqg/2016/) |
+| [`2017/`](./2017/) | THPT QG 2017 — ~861,000 candidates | ~117 MB raw data | [/2017/](https://tiennm99.github.io/thptqg/2017/) (+ [old](https://tiennm99.github.io/thptqg/2017/old/), [old2](https://tiennm99.github.io/thptqg/2017/old2/) generations) |
-The three 2017 datasets are successive publications of the same exam and they
-disagree; all three are kept so the differences stay inspectable.
+Each year was a standalone repository (`thptqg2016`, `thptqg2017`), merged here
+with full history. Each keeps its own `tools/xlsxread` copy because the raw
+data formats differ per year.
-## Layout
-
-```
-index.html + src/ the frontend — one app serving all four datasets and the hub
- datasets.js the four dataset ids and their per-dataset content
- router.js pathname → dataset
-data// raw Excel files, one directory per dataset
-parser/ the Rust parser
- src/schema.rs canonical 22-column table: DDL, INSERT, subject regexes
- configs/.toml per-dataset parse rules only, no SQL
- scripts/ database build, crawler, parity verification
-scripts/ site assembly
-docs/ architecture, data pipeline, deployment
-```
-
-The dataset id is one identifier end to end:
-
-```
-data/2017-old/ → parser/configs/2017-old.toml → db/2017-old.db.gz → /thptqg/2017-old/
-```
-
-## Build
-
-```bash
-npm ci
-npm run build:rust # compile the parser
-npm run build:db # build + gzip all four databases (add an id for just one)
-npm run build:site # one Vite build, then assemble into _site/
-npx serve _site
-```
-
-Pushing to `main` runs the same steps in
-`.github/workflows/deploy-pages.yml` and publishes to GitHub Pages.
-
-## Adding a dataset
-
-1. Put the Excel files in `data//`
-2. Add `parser/configs/.toml` — sheet mode, column indices, validation
- guards. No SQL; the schema is canonical.
-3. Add an entry to `DATASETS` in `src/datasets.js`
-
-Everything else follows: the build script, the site assembly and the router all
-read that one list, and the UI adapts to whichever columns the dataset fills.
-
-## Docs
-
-See [`docs/`](./docs/) — [overview](./docs/project-overview.md),
-[architecture](./docs/system-architecture.md),
-[data pipeline](./docs/data-pipeline.md),
-[deployment](./docs/deployment-guide.md).
+The `Deploy to GitHub Pages` workflow builds both years' databases and site
+bundles and publishes them behind a root index.
diff --git a/docs/README.md b/docs/README.md
deleted file mode 100644
index b2ad89a..0000000
--- a/docs/README.md
+++ /dev/null
@@ -1,6 +0,0 @@
-# Docs
-
-- [`project-overview.md`](./project-overview.md) — goal, scope, constraints, the four datasets, history
-- [`system-architecture.md`](./system-architecture.md) — data flow, canonical schema, routing, how one frontend serves both exam years
-- [`data-pipeline.md`](./data-pipeline.md) — Excel parse quirks, per-dataset formats, overflow-sheet gotcha, expected row counts, verifying a rebuild
-- [`deployment-guide.md`](./deployment-guide.md) — GitHub Pages workflow, adding a dataset, rollback, troubleshooting
diff --git a/docs/data-pipeline.md b/docs/data-pipeline.md
deleted file mode 100644
index aa8d477..0000000
--- a/docs/data-pipeline.md
+++ /dev/null
@@ -1,149 +0,0 @@
-# Data Pipeline
-
-From raw Excel files to a compressed SQLite file the browser can load.
-
-One Rust binary (`parser/`) builds every dataset. What differs per dataset is
-parse rules only — sheet strategy, column layout, validation guards — declared
-in `parser/configs/.toml`. The table shape, the INSERT and the subject
-regexes are canonical and live in `parser/src/schema.rs`.
-
-## Sources
-
-| id | Files | Reproducible | Origin |
-| --- | --- | --- | --- |
-| `2016` | 4 `.xls` + 115 `.xlsx` | no | Bộ GD&ĐT, collected 2016 |
-| `2017` | 63 `.xls` | **yes** | baotintuc.vn CDN |
-| `2017-old` | 63 `.xlsx` | no | pre-refresh archive |
-| `2017-old2` | 54 `.xlsx` | no | corrected re-export |
-
-Only `2017` can be re-fetched:
-
-```bash
-node parser/scripts/crawl-baotintuc.js
-```
-
-Idempotent — skips files already present, saves to `data/2017/`. Source article:
-`https://baotintuc.vn/tuyen-sinh/tra-cuu-diem-thi-thpt-2017-cua-63-tinh-thanh-pho-tren-baotintucvn-20170706073512672.htm`
-
-The other three were collected from Vietnamese news sites at the time and the
-publisher URLs were not recorded. **The files committed in git are the only
-copy.**
-
-## Source Excel shapes
-
-The 2017 datasets share one layout:
-
-| Col | Name | Content |
-| --- | --- | --- |
-| 0 | HO_TEN | full name in Vietnamese |
-| 1 | NGAY_SINH | `dd/mm/yyyy` |
-| 2 | SOBAODANH | 8-digit string, first 2 digits = province |
-| 3 | DIEM_THI | concatenated per-subject scores, e.g. `"Toán: 6.80 Ngữ văn: 5.25 …"` |
-
-2016 has **three** layouts across its 119 files, chosen per file at runtime via
-`format_detection = "thptqg2016"` in its config:
-
-| Format | Detected by | Notes |
-| --- | --- | --- |
-| `separate-scores` | `row[0] == "SBD"` and `row[2] == "TOAN"` | one column per subject; scores read directly, no regex |
-| `mapped` | header contains `SOBAODANH`/`SBD` plus `DIEM_THI` | column indices derived from the header |
-| `default` | no recognised header | positional 6-column layout |
-
-The `mapped` and `default` layouts also carry `TEN_CUMTHI` and `GIOI_TINH`,
-which is why only 2016 populates those columns.
-
-## Score text parsing
-
-`SCORE_PATTERNS` in `parser/src/schema.rs` defines one regex per subject, and
-**all 16 run against every dataset**. A subject a given exam year did not offer
-simply never matches and stays NULL.
-
-| Column | Source label | English | 2016 | 2017 |
-| --- | --- | --- | --- | --- |
-| toan | Toán | Math | ✓ | ✓ |
-| ngu_van | Ngữ văn | Literature | ✓ | ✓ |
-| vat_ly | Vật lí | Physics | ✓ | ✓ |
-| hoa_hoc | Hóa học | Chemistry | ✓ | ✓ |
-| sinh_hoc | Sinh học | Biology | ✓ | ✓ |
-| khtn | KHTN | Natural Sciences combined | — | ✓ |
-| lich_su | Lịch sử | History | ✓ | ✓ |
-| dia_ly | Địa lí | Geography | ✓ | ✓ |
-| gdcd | GDCD | Civic Education | — | ✓ |
-| khxh | KHXH | Social Sciences combined | — | ✓ |
-| tieng_anh | Tiếng Anh | English | ✓ | ✓ |
-| tieng_phap | Tiếng Pháp | French | ✓ | ✓ |
-| tieng_nga | Tiếng Nga | Russian | ✓ | ✓ |
-| tieng_duc | Tiếng Đức | German | ✓ | ✓ |
-| tieng_nhat | Tiếng Nhật | Japanese | ✓ | ✓ |
-| tieng_trung | Tiếng Trung | Chinese | ✓ | ✓ |
-
-### The foreign-language recovery
-
-Before the parser was unified, the 2016 config listed 12 subject patterns and
-the 2017 configs listed 14. Neither list was complete: candidates could sit
-German, Japanese and Russian in both years, so 1,691 students ended up with **no
-foreign-language score at all**.
-
-Running all 16 patterns everywhere recovered them — 182 Russian in 2016, and
-German and Japanese across all three 2017 generations. See
-`plans/reports/parser-parity-result.md` for the evidence that these are real
-scores rather than false matches.
-
-## Per-dataset quirks
-
-| id | Sheet strategy | Guards |
-| --- | --- | --- |
-| `2016` | all sheets | per-file format detection; header-token rows rejected |
-| `2017` | all sheets — Hà Nội and HCM overflow | none |
-| `2017-old` | first sheet only | reject non-numeric SBD (rejects a header leak in this export) |
-| `2017-old2` | all sheets — HCM overflows | reject non-numeric SBD; skip blank rows before counting |
-
-### Overflow-sheet gotcha
-
-The old `.xls` format caps at 65,536 rows per sheet. Hanoi and Ho Chi Minh City
-exceed that, so Excel splits the overflow into `Sheet2`. **Reading only `Sheet1`
-silently drops 13,720 students** (Hanoi +7,275, HCM +6,445). That is what
-`sheet_mode = "all"` exists for.
-
-## Expected row counts
-
-| id | Source rows | Skipped | DB rows |
-| --- | --- | --- | --- |
-| `2016` | 877,464 | 3 duplicate SBDs collapsed | **877,461** |
-| `2017` | 861,131 | 63 empty | **861,068** |
-| `2017-old` | 847,349 | 1 header leak | **847,348** |
-| `2017-old2` | 679,764 | 0 | **679,764** |
-
-## Verifying a rebuild
-
-`parser/scripts/db-stats.js` dumps row counts, per-column non-NULL counts, file
-size and a deterministic student sample as JSON.
-`parser/scripts/verify-parity.js` diffs two such files and exits non-zero on any
-mismatch.
-
-```bash
-node parser/scripts/db-stats.js 2016=.db … > current.json
-node parser/scripts/verify-parity.js plans/reports/parser-parity-baseline.json current.json
-```
-
-The committed baseline was built with the pre-refactor code and cannot be
-regenerated — the two old crates no longer exist. Both scripts use the built-in
-`node:sqlite`, so they need no dependencies.
-
-## Refreshing the 2017 data
-
-```bash
-rm data/2017/*.xls
-node parser/scripts/crawl-baotintuc.js
-node parser/scripts/build-db.js 2017
-```
-
-Then re-run the parity check above and confirm the row count still matches.
-
-## Legacy scripts
-
-`parser/scripts/check-duplicates.js` and `diff-datasets.js` are one-off audits
-that were already broken before the repo was unified — a hardcoded Windows path
-in one, an undeclared `better-sqlite3` dependency and stale paths in the other.
-Each carries a comment saying so. For comparing two builds, use `db-stats.js`
-plus `verify-parity.js` instead.
diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md
deleted file mode 100644
index f5b29b8..0000000
--- a/docs/deployment-guide.md
+++ /dev/null
@@ -1,105 +0,0 @@
-# Deployment Guide
-
-Deploys to GitHub Pages via `.github/workflows/deploy-pages.yml`. Every push to
-`main` rebuilds all four datasets and redeploys the whole site.
-
-One-time setup: **Settings → Pages → Source: GitHub Actions**.
-
-## What the workflow does
-
-1. Checkout, Rust toolchain, Node 24, `npm ci`
-2. `npm run build:rust` — one parser binary
-3. `npm run build:db` — builds and gzips all four databases into
- `.build/public/db/`
-4. `npm run build:site` — one Vite build, then `scripts/assemble-site.js`
-5. `actions/upload-pages-artifact` + `actions/deploy-pages`
-
-The database build dominates the runtime: roughly 419 MB of Excel is parsed on
-every deploy.
-
-## Resulting URLs
-
-```
-https://.github.io/thptqg/
-https://.github.io/thptqg/2016/
-https://.github.io/thptqg/2017/
-https://.github.io/thptqg/2017-old/
-https://.github.io/thptqg/2017-old2/
-```
-
-`/thptqg/2017/old/` and `/thptqg/2017/old2/` were the pre-flattening URLs. They
-are still served, and the router rewrites them to the flat form with the query
-string intact.
-
-## Local reproduction
-
-```bash
-npm ci
-npm run build:rust
-npm run build:db # all four; pass an id to build just one
-npm run build:site # vite build + assemble into _site/
-npx serve _site
-```
-
-To rebuild a single dataset:
-
-```bash
-node parser/scripts/build-db.js 2017-old
-```
-
-## Base path
-
-`vite.config.js` sets `base: "/thptqg/"`. If you fork under a different repo
-name, update it to match — assets are referenced absolutely, so a mismatch shows
-up as a blank page with 404s on `/assets/...`.
-
-## Adding a dataset
-
-1. Put the Excel files in `data//`
-2. Add `parser/configs/.toml` with the parse rules — sheet mode, column
- indices, SBD validation, header tokens, blank-row stripping. No SQL: the
- schema is canonical and lives in `parser/src/schema.rs`
-3. Add an entry to `DATASETS` in `src/datasets.js`
-
-Nothing else. The build script, the site assembly and the router all read that
-one list, and the frontend adapts to whichever columns the dataset populates.
-
-## Why no uncompressed database can ship
-
-`build-db.js` runs `gzip -9` **without** `-k`, so the raw file does not survive
-the build. `assemble-site.js` then fails the job if any `.db`, `.db-journal`,
-`.db-wal` or `.db-shm` reached the output.
-
-Both guards exist because the previous pipeline wrote a 100+ MB uncompressed
-database into the source tree and relied on an `rm` step to keep it out of the
-artifact — one missing line away from publishing it.
-
-## Notes
-
-- **The gzipped database is not cacheable across deploys.** Every rebuild
- produces a different `.db.gz`, because SQLite does not lay pages out
- deterministically. First-visit users pay the full download; later visits hit
- browser cache until the next deploy.
-- **GitHub Pages caps individual files at 100 MB.** The largest gzipped database
- is about 48 MB. Uncompressed they run 135–234 MB and would not fit — which is
- why the browser decompresses via `DecompressionStream`.
-- **No server-side compression is assumed.** The app fetches the `.gz` bytes
- directly rather than relying on `Content-Encoding: gzip`; Pages does not
- reliably compress arbitrary paths on the fly.
-- **Total artifact is about 177 MB**, well inside the 1 GB site limit.
-
-## Rollback
-
-Deploys are stateless snapshots. Revert the commit on `main` and push; the next
-run rebuilds the older state. There is no data to migrate.
-
-## Troubleshooting
-
-| Symptom | Typical cause |
-| --- | --- |
-| Blank page, 404 on assets | `base` in `vite.config.js` does not match the repo name |
-| `Failed to fetch database: 404` | Dataset id in `src/datasets.js` does not match the file in `db/` |
-| A route 404s | `assemble-site.js` did not run, or the id is missing from `DATASETS` |
-| WASM fails to load | `sql.js.org` unreachable — self-host `sql-wasm.wasm` and update `SQL_WASM_URL` in `use-sqlite.js` |
-| Deploy fails on assembly | An uncompressed database artefact reached the output; the error names the files |
-| Missing rows after a data update | Unknown Excel header — check the per-file row counts the parser prints |
diff --git a/docs/project-overview.md b/docs/project-overview.md
deleted file mode 100644
index af8dfdd..0000000
--- a/docs/project-overview.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Project Overview
-
-## Goal
-
-A public lookup tool for Vietnam's National High School Graduation Exam scores,
-running entirely in the browser and hosted for free on GitHub Pages. Covers the
-2016 and 2017 exams — 1.7 million candidates across four datasets.
-
-## Scope
-
-- Lookup by exam ID or full name, with Vietnamese diacritics handled
-- Read-only SQL queries against a single `student` table
-- Admission-block (khối thi) totals computed per candidate
-- Static datasets — both exams are long over and the data is frozen
-
-## Target users
-
-- Former candidates checking their scores
-- Education researchers and data journalists running aggregate statistics
-- Developers exploring SQL against a real-world dataset
-
-## Constraints
-
-- **Zero backend.** The full database (38–48 MB gzipped per dataset) is
- downloaded to the browser and queried in-process.
-- **Read-only.** `INSERT`/`UPDATE`/`DELETE` are rejected, so nobody is misled
- into thinking edits persist. `sql.js` is in-memory anyway.
-- **Row caps.** 100 rows for lookups, 1000 for custom SQL, to prevent browser
- hangs.
-- **Vietnamese-first UI.** App labels and data are Vietnamese; documentation is
- English.
-
-## Datasets
-
-| id | Exam | Candidates | Notes |
-| --- | --- | --- | --- |
-| `2016` | 2016 | 877,461 | 119 files, three column layouts |
-| `2017` | 2017 | 861,068 | current generation, reproducible from source |
-| `2017-old` | 2017 | 847,348 | pre-refresh archive |
-| `2017-old2` | 2017 | 679,764 | corrected re-export |
-
-The three 2017 datasets are kept side by side because they disagree, and the
-disagreement is itself informative. Only `2017` is re-fetchable; the rest exist
-solely as the copies committed here.
-
-Original 2016 aggregator link
-()
-is no longer accessible, which is why the raw files are mirrored in `data/2016/`.
-
-## History
-
-Each year began as a standalone repository (`thptqg2016`, `thptqg2017`), merged
-here with full history. They initially kept separate frontends and separate
-copies of the same Rust parser, synchronised by hand. That duplication was
-removed: there is now one frontend, one parser, and one canonical schema, with
-per-dataset differences confined to four small config files and one registry
-entry each.
-
-The unification also fixed a latent data-loss bug — neither year's parser
-config listed the complete set of subjects, so 1,691 candidates were missing
-their foreign-language score. See `data-pipeline.md`.
-
-## Status
-
-Stable, data frozen. Work is limited to UX polish and keeping the pipeline
-maintainable.
diff --git a/docs/system-architecture.md b/docs/system-architecture.md
deleted file mode 100644
index 7eb5cce..0000000
--- a/docs/system-architecture.md
+++ /dev/null
@@ -1,180 +0,0 @@
-# System Architecture
-
-Static site, no backend. The browser downloads a compressed SQLite file at boot
-and every query runs locally via `sql.js` (SQLite compiled to WebAssembly).
-
-One frontend, one parser, one schema, four datasets.
-
-## Data flow
-
-```
-data//*.xls(x)
- │
- ▼ parser/ (Rust, one binary, one config per dataset)
- .build/public/db/.db
- │
- ▼ gzip -9 (no -k: the raw file does not survive)
- .build/public/db/.db.gz
- │
- ▼ vite build (publicDir = .build/public)
- dist/
- │
- ▼ scripts/assemble-site.js
- _site/ → GitHub Pages
- │
- ▼ browser
- sql.js (WASM) opens the .db → queries run client-side
-```
-
-## The dataset id
-
-One identifier ties the whole pipeline together:
-
-```
-data/2017-old/ → parser/configs/2017-old.toml → db/2017-old.db.gz → /thptqg/2017-old/
-```
-
-`src/datasets.js` declares the four ids once. The frontend, the database build
-(`parser/scripts/build-db.js`) and the site assembly all import that list, so
-adding a dataset means adding one entry and one config file.
-
-| id | Exam | Rows | Source |
-| --- | --- | --- | --- |
-| `2016` | 2016 | 877,461 | Bộ GD&ĐT |
-| `2017` | 2017 | 861,068 | baotintuc.vn |
-| `2017-old` | 2017 | 847,348 | pre-refresh archive |
-| `2017-old2` | 2017 | 679,764 | corrected re-export |
-
-## Canonical schema
-
-Defined once in `parser/src/schema.rs` — DDL, INSERT, column order and the 16
-subject regexes. The four TOML configs carry no SQL at all, only per-dataset
-parse rules. Config parsing uses `deny_unknown_fields`, so a leftover `[schema]`
-block fails loudly instead of looking effective while `schema.rs` drives the
-build.
-
-```sql
-CREATE TABLE student (
- so_bao_danh TEXT PRIMARY KEY, -- 2017: 8 digits; 2016: 9 digits or a
- -- 2-4 letter cluster code then digits
- ho_ten TEXT NOT NULL,
- ho_ten_ascii TEXT NOT NULL, -- NFD-stripped lowercase, for accent-insensitive search
- ngay_sinh TEXT, -- dd/mm/yyyy
- ten_cum_thi TEXT, -- 2016 only
- gioi_tinh TEXT, -- 2016 only
- toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
- lich_su, dia_ly, gdcd, khxh,
- tieng_anh, tieng_phap, tieng_nga, tieng_duc, tieng_nhat, tieng_trung REAL
-);
-CREATE INDEX idx_ho_ten ON student(ho_ten);
-CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
-CREATE INDEX idx_ten_cum_thi ON student(ten_cum_thi) WHERE ten_cum_thi IS NOT NULL;
-```
-
-Every dataset gets all 22 columns; ones it has no data for are NULL, costing
-about a byte per row. `khtn`, `khxh` and `gdcd` are empty on 2016;
-`ten_cum_thi` and `gioi_tinh` are empty on the 2017 datasets.
-
-`idx_ten_cum_thi` is partial, so it holds zero entries where the column is
-always NULL.
-
-## Routing
-
-URLs are flat, one segment per dataset, and the segment is the id:
-
-```
-/thptqg/ hub
-/thptqg/2016/
-/thptqg/2017/
-/thptqg/2017-old/
-/thptqg/2017-old2/
-```
-
-`src/router.js` is an exact match on that segment. The nested form used before
-(`/thptqg/2017/old/`) would have needed longest-prefix matching, since it also
-starts with `/thptqg/2017/`. Those two legacy URLs still resolve: the router
-rewrites them to the flat equivalent with `history.replaceState`, preserving the
-query string so `?q=` deep links survive.
-
-A single Vite build emits one `index.html`. Because `base` is absolute
-(`/thptqg/`), that file references `/thptqg/assets/...` regardless of the
-directory it is served from, so the assemble step copies it to every route and
-each URL is a real static file. **No SPA 404-fallback redirect is used** — the
-usual hack rewrites URLs and would interfere with the deep links.
-
-## Serving both exam years without branching
-
-No component contains a per-dataset conditional. Two mechanisms do the work:
-
-- **All-NULL columns are hidden.** `score-table.jsx` drops any column where
- every row in the result set is NULL, so 2016 rows surface Cụm thi / GT / Đức /
- Nhật and 2017 rows surface KHTN / KHXH / GDCD / Nga.
-- **Incomplete admission blocks are skipped.** `computeBlocks()` only returns a
- block when the student has all three subjects, so one block list covers both
- years: GDCD blocks self-exclude on 2016, German and Japanese blocks
- self-exclude wherever those languages were not sat.
-
-Anything genuinely per-dataset — title, source, database size, search examples,
-SQL presets — lives in `src/datasets.js`.
-
-## Exam ID formats
-
-`src/lib/query-mode.js` decides whether a query is an exam ID or a name, and is
-shared by `App.jsx` and `search-form.jsx` (they previously held separate copies
-and had drifted apart on exactly this rule).
-
-| Form | Example | Where |
-| --- | --- | --- |
-| 8 digits | `49008235` | 2017 — first two digits are the province |
-| 9 digits with leading zero | `017006021` | 2016 |
-| 2-4 letters then digits | `BAL000001` | 2016 — exam cluster code |
-
-The letter-prefixed form is the majority case for 2016: 616,593 of 877,461
-candidates (70.3%). Letter prefixes are upper-cased before lookup, so
-`bal000001` resolves.
-
-## Score tiers
-
-Six-level ladder in `scoreTier()` (`src/lib/admission-blocks.js`), paired with a
-symbol so meaning is never colour-only.
-
-| Tier | Range | Vietnamese |
-| --- | --- | --- |
-| common | ≤ 1 | Điểm liệt |
-| uncommon | < 5 | Chưa đạt |
-| rare | 5–6.5 | Trung bình |
-| epic | 6.5–8 | Khá |
-| legendary | 8–9 | Giỏi |
-| prismatic | 9–10 | Xuất sắc |
-
-## Admission blocks
-
-Vietnamese universities admit on three-subject combinations (khối thi).
-`src/lib/admission-blocks.js` lists the blocks computable from this schema
-(A00–A11, B00–B08, C00–C20, D01–D15, plus D05/D06 for German and Japanese).
-`computeBlocks(student)` returns those where all three scores exist, sorted by
-total descending.
-
-## Design decisions
-
-| Concern | Choice | Rationale |
-| --- | --- | --- |
-| Storage | Static SQLite file | No backend; the datasets are frozen |
-| Compression | gzip in CI, `DecompressionStream` in the browser | Native API, no extra library |
-| WASM hosting | `sql.js.org` CDN | Smaller self-hosted artifact |
-| Diacritics search | Pre-computed `ho_ten_ascii` | `LOWER(REPLACE(...))` at query time defeats the index |
-| SQL safety | Leading-keyword allowlist | `sql.js` is in-memory so writes cannot persist; the allowlist prevents confusion |
-| Row caps | 100 (lookup), 1000 (SQL) | Keeps DOM render sizes reasonable |
-| Routing | Hand-rolled, ~20 lines | Five static routes do not justify a router dependency |
-
-## Risks and limitations
-
-- **Database size.** 38–48 MB gzipped per dataset; slow links wait, mitigated by
- a progress bar.
-- **Browser memory.** The full database lives in RAM; older mobile devices may
- run out.
-- **`sql.js.org` dependency.** If that CDN is unreachable, the WASM fails to
- load. Self-hosting `sql-wasm.wasm` and updating `SQL_WASM_URL` in
- `use-sqlite.js` is the fix.
-- **Excel format drift.** A new source file with an unseen header layout needs a
- new branch in `format_detect_2016.rs` or a new config.
diff --git a/index.html b/index.html
index 6961ca3..adbaed1 100644
--- a/index.html
+++ b/index.html
@@ -1,19 +1,26 @@
-
-
-
-
-
-
-
- Tra cứu điểm thi THPT QG 2017
-
-
-
-
-
+
+
+
+ Tra cứu điểm thi THPT Quốc gia
+
+
+
+ Tra cứu điểm thi THPT Quốc gia
+ Tra cứu điểm thi tốt nghiệp THPT Quốc gia theo số báo danh — truy vấn SQL
+ chạy hoàn toàn trên trình duyệt (sql.js).
+
+ Phiên bản cũ của trang 2017: old ·
+ old2
+
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index cc1c89f..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,2569 +0,0 @@
-{
- "name": "thptqg",
- "version": "1.0.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "thptqg",
- "version": "1.0.0",
- "license": "ISC",
- "dependencies": {
- "react": "^19.2.4",
- "react-dom": "^19.2.4",
- "sql.js": "^1.14.1"
- },
- "devDependencies": {
- "@eslint/js": "^9.39.4",
- "@types/react": "^19.2.14",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^6.0.1",
- "eslint": "^9.39.4",
- "eslint-plugin-react-hooks": "^7.0.1",
- "eslint-plugin-react-refresh": "^0.5.2",
- "globals": "^17.4.0",
- "vite": "^8.0.16"
- }
- },
- "node_modules/@babel/code-frame": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
- "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-validator-identifier": "^7.29.7",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.1.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/compat-data": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
- "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/core": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
- "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
- "@babel/helper-compilation-targets": "^7.29.7",
- "@babel/helper-module-transforms": "^7.29.7",
- "@babel/helpers": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/template": "^7.29.7",
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7",
- "@jridgewell/remapping": "^2.3.5",
- "convert-source-map": "^2.0.0",
- "debug": "^4.1.0",
- "gensync": "^1.0.0-beta.2",
- "json5": "^2.2.3",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/babel"
- }
- },
- "node_modules/@babel/generator": {
- "version": "7.29.8",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
- "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.29.8",
- "@babel/types": "^7.29.8",
- "@jridgewell/gen-mapping": "^0.3.12",
- "@jridgewell/trace-mapping": "^0.3.28",
- "jsesc": "^3.0.2"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-compilation-targets": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
- "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/compat-data": "^7.29.7",
- "@babel/helper-validator-option": "^7.29.7",
- "browserslist": "^4.24.0",
- "lru-cache": "^5.1.1",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-globals": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
- "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-imports": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
- "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-transforms": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
- "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-module-imports": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7",
- "@babel/traverse": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
- "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
- "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-option": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
- "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helpers": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
- "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.29.8",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
- "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.29.8"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/template": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
- "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/traverse": {
- "version": "7.29.8",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
- "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.8",
- "@babel/helper-globals": "^7.29.7",
- "@babel/parser": "^7.29.8",
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.8",
- "debug": "^4.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.29.8",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
- "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@eslint-community/eslint-utils": {
- "version": "4.10.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
- "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-visitor-keys": "^3.4.3"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- },
- "peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
- }
- },
- "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
- "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint-community/regexpp": {
- "version": "4.12.2",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
- "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
- }
- },
- "node_modules/@eslint/config-array": {
- "version": "0.21.2",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
- "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/object-schema": "^2.1.7",
- "debug": "^4.3.1",
- "minimatch": "^3.1.5"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- }
- },
- "node_modules/@eslint/config-helpers": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
- "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/core": "^0.17.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- }
- },
- "node_modules/@eslint/core": {
- "version": "0.17.0",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
- "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@types/json-schema": "^7.0.15"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- }
- },
- "node_modules/@eslint/eslintrc": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz",
- "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ajv": "^6.14.0",
- "debug": "^4.3.2",
- "espree": "^10.0.1",
- "globals": "^14.0.0",
- "ignore": "^5.2.0",
- "import-fresh": "^3.2.1",
- "js-yaml": "^4.3.0",
- "minimatch": "^3.1.5",
- "strip-json-comments": "^3.1.1"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint/eslintrc/node_modules/globals": {
- "version": "14.0.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
- "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@eslint/js": {
- "version": "9.39.5",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
- "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- }
- },
- "node_modules/@eslint/object-schema": {
- "version": "2.1.7",
- "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
- "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- }
- },
- "node_modules/@eslint/plugin-kit": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
- "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/core": "^0.17.0",
- "levn": "^0.4.1"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- }
- },
- "node_modules/@humanfs/core": {
- "version": "0.19.2",
- "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
- "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanfs/types": "^0.15.0"
- },
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanfs/node": {
- "version": "0.16.8",
- "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
- "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanfs/core": "^0.19.2",
- "@humanfs/types": "^0.15.0",
- "@humanwhocodes/retry": "^0.4.0"
- },
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanfs/types": {
- "version": "0.15.0",
- "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
- "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
- "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.22"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@humanwhocodes/retry": {
- "version": "0.4.3",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
- "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18.18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
- "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
- "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@oxc-project/types": {
- "version": "0.144.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
- "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/Boshen"
- }
- },
- "node_modules/@rolldown/binding-android-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
- "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
- "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
- "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
- "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
- "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
- "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
- "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
- "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
- "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
- "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
- "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
- "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
- "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
- "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
- "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/json-schema": {
- "version": "7.0.15",
- "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
- "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/react": {
- "version": "19.2.18",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
- "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "csstype": "^3.2.2"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
- "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "^19.2.0"
- }
- },
- "node_modules/@vitejs/plugin-react": {
- "version": "6.0.5",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
- "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@rolldown/pluginutils": "^1.0.1"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "peerDependencies": {
- "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
- "babel-plugin-react-compiler": "^1.0.0",
- "vite": "^8.0.0"
- },
- "peerDependenciesMeta": {
- "@rolldown/plugin-babel": {
- "optional": true
- },
- "babel-plugin-react-compiler": {
- "optional": true
- }
- }
- },
- "node_modules/acorn": {
- "version": "8.18.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
- "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-jsx": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
- "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/ajv": {
- "version": "6.15.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
- "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/argparse": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
- "license": "Python-2.0"
- },
- "node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/baseline-browser-mapping": {
- "version": "2.11.13",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
- "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "baseline-browser-mapping": "dist/cli.cjs"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/brace-expansion": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
- "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/browserslist": {
- "version": "4.28.8",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
- "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "baseline-browser-mapping": "^2.11.12",
- "caniuse-lite": "^1.0.30001809",
- "electron-to-chromium": "^1.5.402",
- "node-releases": "^2.0.53",
- "update-browserslist-db": "^1.3.0"
- },
- "bin": {
- "browserslist": "cli.js"
- },
- "engines": {
- "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
- }
- },
- "node_modules/callsites": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
- "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/caniuse-lite": {
- "version": "1.0.30001809",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
- "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "CC-BY-4.0"
- },
- "node_modules/chalk": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
- "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/convert-source-map": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
- "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/cross-spawn": {
- "version": "7.0.6",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
- "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/deep-is": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
- "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/electron-to-chromium": {
- "version": "1.5.405",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
- "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/escalade": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/eslint": {
- "version": "9.39.5",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
- "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.8.0",
- "@eslint-community/regexpp": "^4.12.1",
- "@eslint/config-array": "^0.21.2",
- "@eslint/config-helpers": "^0.4.2",
- "@eslint/core": "^0.17.0",
- "@eslint/eslintrc": "^3.3.6",
- "@eslint/js": "9.39.5",
- "@eslint/plugin-kit": "^0.4.1",
- "@humanfs/node": "^0.16.6",
- "@humanwhocodes/module-importer": "^1.0.1",
- "@humanwhocodes/retry": "^0.4.2",
- "@types/estree": "^1.0.6",
- "ajv": "^6.14.0",
- "chalk": "^4.0.0",
- "cross-spawn": "^7.0.6",
- "debug": "^4.3.2",
- "escape-string-regexp": "^4.0.0",
- "eslint-scope": "^8.4.0",
- "eslint-visitor-keys": "^4.2.1",
- "espree": "^10.4.0",
- "esquery": "^1.5.0",
- "esutils": "^2.0.2",
- "fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^8.0.0",
- "find-up": "^5.0.0",
- "glob-parent": "^6.0.2",
- "ignore": "^5.2.0",
- "imurmurhash": "^0.1.4",
- "is-glob": "^4.0.0",
- "json-stable-stringify-without-jsonify": "^1.0.1",
- "lodash.merge": "^4.6.2",
- "minimatch": "^3.1.5",
- "natural-compare": "^1.4.0",
- "optionator": "^0.9.3"
- },
- "bin": {
- "eslint": "bin/eslint.js"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- },
- "peerDependencies": {
- "jiti": "*"
- },
- "peerDependenciesMeta": {
- "jiti": {
- "optional": true
- }
- }
- },
- "node_modules/eslint-plugin-react-hooks": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
- "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/core": "^7.24.4",
- "@babel/parser": "^7.24.4",
- "hermes-parser": "^0.25.1",
- "zod": "^3.25.0 || ^4.0.0",
- "zod-validation-error": "^3.5.0 || ^4.0.0"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
- }
- },
- "node_modules/eslint-plugin-react-refresh": {
- "version": "0.5.4",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.4.tgz",
- "integrity": "sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "eslint": "^9 || ^10"
- }
- },
- "node_modules/eslint-scope": {
- "version": "8.4.0",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
- "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "esrecurse": "^4.3.0",
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-visitor-keys": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
- "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/espree": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
- "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.15.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^4.2.1"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/esquery": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
- "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "estraverse": "^5.1.0"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/esrecurse": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
- "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/esutils": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
- "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/fast-deep-equal": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-json-stable-stringify": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
- "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-levenshtein": {
- "version": "2.0.6",
- "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
- "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/file-entry-cache": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
- "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flat-cache": "^4.0.0"
- },
- "engines": {
- "node": ">=16.0.0"
- }
- },
- "node_modules/find-up": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
- "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "locate-path": "^6.0.0",
- "path-exists": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/flat-cache": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
- "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flatted": "^3.2.9",
- "keyv": "^4.5.4"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/flatted": {
- "version": "3.4.4",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
- "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/gensync": {
- "version": "1.0.0-beta.2",
- "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
- "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/glob-parent": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
- "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.3"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/globals": {
- "version": "17.11.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz",
- "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/has-flag": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
- "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/hermes-estree": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
- "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/hermes-parser": {
- "version": "0.25.1",
- "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
- "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "hermes-estree": "0.25.1"
- }
- },
- "node_modules/ignore": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
- "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/import-fresh": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
- "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "parent-module": "^1.0.0",
- "resolve-from": "^4.0.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
- "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.19"
- }
- },
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-glob": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-extglob": "^2.1.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/js-tokens": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/js-yaml": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
- "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/puzrin"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/nodeca"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
- "node_modules/jsesc": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
- "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jsesc": "bin/jsesc"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-stable-stringify-without-jsonify": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
- "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json5": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
- "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "json5": "lib/cli.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/keyv": {
- "version": "4.5.4",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
- "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "json-buffer": "3.0.1"
- }
- },
- "node_modules/levn": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
- "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1",
- "type-check": "~0.4.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/lightningcss": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
- "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
- "dev": true,
- "license": "MPL-2.0",
- "dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.33.0",
- "lightningcss-darwin-arm64": "1.33.0",
- "lightningcss-darwin-x64": "1.33.0",
- "lightningcss-freebsd-x64": "1.33.0",
- "lightningcss-linux-arm-gnueabihf": "1.33.0",
- "lightningcss-linux-arm64-gnu": "1.33.0",
- "lightningcss-linux-arm64-musl": "1.33.0",
- "lightningcss-linux-x64-gnu": "1.33.0",
- "lightningcss-linux-x64-musl": "1.33.0",
- "lightningcss-win32-arm64-msvc": "1.33.0",
- "lightningcss-win32-x64-msvc": "1.33.0"
- }
- },
- "node_modules/lightningcss-android-arm64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
- "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-arm64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
- "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
- "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
- "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
- "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
- "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
- "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
- "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
- "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
- "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
- "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/locate-path": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
- "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-locate": "^5.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/lodash.merge": {
- "version": "4.6.2",
- "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
- "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/lru-cache": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
- "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "yallist": "^3.0.2"
- }
- },
- "node_modules/minimatch": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
- "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nanoid": {
- "version": "3.3.18",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
- "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/natural-compare": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
- "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/node-releases": {
- "version": "2.0.53",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
- "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/optionator": {
- "version": "0.9.4",
- "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
- "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "deep-is": "^0.1.3",
- "fast-levenshtein": "^2.0.6",
- "levn": "^0.4.1",
- "prelude-ls": "^1.2.1",
- "type-check": "^0.4.0",
- "word-wrap": "^1.2.5"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/p-limit": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
- "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "yocto-queue": "^0.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-locate": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
- "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-limit": "^3.0.2"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/parent-module": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
- "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "callsites": "^3.0.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/path-exists": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
- "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.26",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
- "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.17",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/prelude-ls": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
- "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/react": {
- "version": "19.2.8",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
- "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-dom": {
- "version": "19.2.8",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
- "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
- "license": "MIT",
- "dependencies": {
- "scheduler": "^0.27.0"
- },
- "peerDependencies": {
- "react": "^19.2.8"
- }
- },
- "node_modules/resolve-from": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
- "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/rolldown": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
- "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@oxc-project/types": "=0.144.0",
- "@rolldown/pluginutils": "^1.0.0"
- },
- "bin": {
- "rolldown": "bin/cli.mjs"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.2.4",
- "@rolldown/binding-darwin-arm64": "1.2.4",
- "@rolldown/binding-darwin-x64": "1.2.4",
- "@rolldown/binding-freebsd-x64": "1.2.4",
- "@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
- "@rolldown/binding-linux-arm64-gnu": "1.2.4",
- "@rolldown/binding-linux-arm64-musl": "1.2.4",
- "@rolldown/binding-linux-ppc64-gnu": "1.2.4",
- "@rolldown/binding-linux-s390x-gnu": "1.2.4",
- "@rolldown/binding-linux-x64-gnu": "1.2.4",
- "@rolldown/binding-linux-x64-musl": "1.2.4",
- "@rolldown/binding-openharmony-arm64": "1.2.4",
- "@rolldown/binding-win32-arm64-msvc": "1.2.4",
- "@rolldown/binding-win32-x64-msvc": "1.2.4"
- }
- },
- "node_modules/scheduler": {
- "version": "0.27.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
- "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
- "license": "MIT"
- },
- "node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
- "node_modules/shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/sql.js": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
- "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
- "license": "MIT"
- },
- "node_modules/strip-json-comments": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
- "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/supports-color": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
- "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.17",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
- "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.4"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/type-check": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
- "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/update-browserslist-db": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
- "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "escalade": "^3.2.0",
- "picocolors": "^1.1.1"
- },
- "bin": {
- "update-browserslist-db": "cli.js"
- },
- "peerDependencies": {
- "browserslist": ">= 4.21.0"
- }
- },
- "node_modules/uri-js": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
- "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "punycode": "^2.1.0"
- }
- },
- "node_modules/vite": {
- "version": "8.2.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
- "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "lightningcss": "^1.33.0",
- "picomatch": "^4.0.5",
- "postcss": "^8.5.25",
- "rolldown": "~1.2.1",
- "tinyglobby": "^0.2.17"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.4.0",
- "esbuild": "^0.27.0 || ^0.28.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "@vitejs/devtools": {
- "optional": true
- },
- "esbuild": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/word-wrap": {
- "version": "1.2.5",
- "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
- "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/yallist": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
- "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/yocto-queue": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
- "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/zod": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
- "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
- },
- "node_modules/zod-validation-error": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
- "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18.0.0"
- },
- "peerDependencies": {
- "zod": "^3.25.0 || ^4.0.0"
- }
- }
- }
-}
diff --git a/parser/configs/2016.toml b/parser/configs/2016.toml
deleted file mode 100644
index 6bde781..0000000
--- a/parser/configs/2016.toml
+++ /dev/null
@@ -1,32 +0,0 @@
-# thptqg2016 data/ — 4 .xls + 115 .xlsx mixed files.
-#
-# Three column layouts exist across the 119 files; the binary selects the
-# right one per-file at runtime via format_detection = "thptqg2016":
-#
-# separate-scores header SBD(0)/HOTEN(1)/TOAN(2)... — dhhanghai files
-# mapped header SOBAODANH|SBD + DIEM_THI — most provinces
-# default no header; positional 6-col layout — remaining files
-#
-# This is the only dataset that populates ten_cum_thi and gioi_tinh, and the
-# only one whose files carry Tiếng Đức / Tiếng Nhật scores.
-#
-# sheet_mode = "all": several provinces overflow into Sheet2 (65k Excel row cap).
-# strip_blank_rows = false: no blank-row anomaly observed in this dataset.
-#
-# Table shape, INSERT and subject regexes are canonical — see src/schema.rs.
-
-format_detection = "thptqg2016"
-
-[reader]
-sheet_mode = "all"
-strip_blank_rows = false
-
-[validation]
-require_numeric_sbd = false
-require_nonempty_name = true
-require_nonempty_sbd = true
-
-[header]
-# Tokens that identify a header row by first-cell content (uppercased).
-# Covers both SOBAODANH-style and SBD-style headers.
-tokens = ["SOBAODANH", "SBD", "HO_TEN", "HOTEN", "HỌ TÊN", "STT"]
diff --git a/parser/configs/2017-old.toml b/parser/configs/2017-old.toml
deleted file mode 100644
index f21f8aa..0000000
--- a/parser/configs/2017-old.toml
+++ /dev/null
@@ -1,25 +0,0 @@
-# thptqg2017 data-old/ — 63 .xlsx files (pre-baotintuc refresh).
-#
-# sheet_mode = "first": single-sheet workbooks, never hit the 65k row cap.
-# SBD validation: require ^\d+$ (build-database-old.js:55 guard).
-# strip_blank_rows = false: no explicit blank-skip in build-database-old.js.
-#
-# Table shape, INSERT and subject regexes are canonical — see src/schema.rs.
-
-[reader]
-sheet_mode = "first"
-strip_blank_rows = false
-
-[columns]
-ho_ten = 0
-ngay_sinh = 1
-so_bao_danh = 2
-diem_thi = 3
-
-[validation]
-require_numeric_sbd = true
-require_nonempty_name = true
-require_nonempty_sbd = true
-
-[header]
-tokens = ["HO_TEN", "HỌ TÊN", "STT"]
diff --git a/parser/configs/2017-old2.toml b/parser/configs/2017-old2.toml
deleted file mode 100644
index c4fd6b7..0000000
--- a/parser/configs/2017-old2.toml
+++ /dev/null
@@ -1,26 +0,0 @@
-# thptqg2017 data-old2/ — 54 .xlsx files (corrected-export set).
-#
-# sheet_mode = "all": 24.HCM_UTLQ.xlsx overflows into Sheet2 (+6,446 rows).
-# SBD validation: require ^\d+$ (build-database-old2.js:57 guard).
-# strip_blank_rows = true: skip fully blank rows BEFORE counting sourceRows
-# (build-database-old2.js:50-51 checks blank before sourceRows++).
-#
-# Table shape, INSERT and subject regexes are canonical — see src/schema.rs.
-
-[reader]
-sheet_mode = "all"
-strip_blank_rows = true
-
-[columns]
-ho_ten = 0
-ngay_sinh = 1
-so_bao_danh = 2
-diem_thi = 3
-
-[validation]
-require_numeric_sbd = true
-require_nonempty_name = true
-require_nonempty_sbd = true
-
-[header]
-tokens = ["HO_TEN", "HỌ TÊN", "STT"]
diff --git a/parser/configs/2017.toml b/parser/configs/2017.toml
deleted file mode 100644
index 99dccbb..0000000
--- a/parser/configs/2017.toml
+++ /dev/null
@@ -1,25 +0,0 @@
-# thptqg2017 data/ — 63 .xls files from baotintuc.vn (current generation).
-#
-# sheet_mode = "all": Hà Nội and HCM overflow into Sheet2 (65k Excel row cap).
-# SBD validation: no numeric guard — build-database.js did not apply ^\d+$.
-# strip_blank_rows = false: no blank-row anomaly in this dataset.
-#
-# Table shape, INSERT and subject regexes are canonical — see src/schema.rs.
-
-[reader]
-sheet_mode = "all"
-strip_blank_rows = false
-
-[columns]
-ho_ten = 0
-ngay_sinh = 1
-so_bao_danh = 2
-diem_thi = 3
-
-[validation]
-require_numeric_sbd = false
-require_nonempty_name = true
-require_nonempty_sbd = true
-
-[header]
-tokens = ["HO_TEN", "HỌ TÊN", "STT"]
diff --git a/parser/scripts/build-db.js b/parser/scripts/build-db.js
deleted file mode 100644
index 9558ebb..0000000
--- a/parser/scripts/build-db.js
+++ /dev/null
@@ -1,66 +0,0 @@
-#!/usr/bin/env node
-/**
- * Build the SQLite database for one or all datasets, then gzip it.
- *
- * Replaces the six per-dataset npm scripts the two old projects carried. The
- * dataset list comes from src/datasets.js so it is written in exactly one place.
- *
- * Output goes to .build/public/db/ — the directory Vite copies as its publicDir.
- * Only the .gz survives: shipping a 100+ MB uncompressed database is made
- * structurally impossible rather than left to a cleanup step.
- *
- * Usage:
- * node parser/scripts/build-db.js # all four datasets
- * node parser/scripts/build-db.js 2017-old # just one
- */
-
-import { execFileSync } from "node:child_process";
-import { mkdirSync, rmSync, existsSync } from "node:fs";
-import { dirname, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-
-import { DATASET_IDS } from "../../src/datasets.js";
-
-const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
-const BIN = resolve(ROOT, "parser/target/release/xlsxread");
-const OUT_DIR = resolve(ROOT, ".build/public/db");
-
-const requested = process.argv.slice(2);
-const unknown = requested.filter((id) => !DATASET_IDS.includes(id));
-if (unknown.length) {
- console.error(`unknown dataset(s): ${unknown.join(", ")}`);
- console.error(`known: ${DATASET_IDS.join(", ")}`);
- process.exit(2);
-}
-const targets = requested.length ? requested : DATASET_IDS;
-
-if (!existsSync(BIN)) {
- console.error(`parser binary not found at ${BIN}`);
- console.error("run: npm run build:rust");
- process.exit(1);
-}
-
-mkdirSync(OUT_DIR, { recursive: true });
-
-for (const id of targets) {
- const db = resolve(OUT_DIR, `${id}.db`);
-
- execFileSync(
- BIN,
- [
- "build",
- "--schema",
- resolve(ROOT, `parser/configs/${id}.toml`),
- "--input",
- resolve(ROOT, `data/${id}`),
- "--output",
- db,
- ],
- { stdio: "inherit" },
- );
-
- // -9 without -k: the raw .db must not reach the published artifact.
- rmSync(`${db}.gz`, { force: true });
- execFileSync("gzip", ["-9", db], { stdio: "inherit" });
- console.log(` → db/${id}.db.gz\n`);
-}
diff --git a/parser/scripts/db-stats.js b/parser/scripts/db-stats.js
deleted file mode 100644
index 8582821..0000000
--- a/parser/scripts/db-stats.js
+++ /dev/null
@@ -1,79 +0,0 @@
-#!/usr/bin/env node
-/**
- * Dump per-dataset statistics from built SQLite databases as JSON.
- *
- * Used twice: once against the pre-refactor databases to capture a baseline,
- * and again after the schema unification. Comparing the two outputs is what
- * proves no score data was lost or invented.
- *
- * Schema-agnostic on purpose — columns come from PRAGMA table_info, so the same
- * script runs against the old 18/20-column tables and the new 22-column one.
- *
- * Usage:
- * node db-stats.js = [= ...] > stats.json
- */
-
-import { DatabaseSync } from "node:sqlite";
-import { statSync } from "node:fs";
-
-// Deterministic value-level sample: every SBD ending in these digits.
-// Re-running against a rebuilt DB compares the same students.
-const SAMPLE_SUFFIX = "0000";
-
-function collect(dbPath) {
- const db = new DatabaseSync(dbPath, { readOnly: true });
-
- const columns = db
- .prepare("PRAGMA table_info(student)")
- .all()
- .map((c) => c.name);
-
- const rowCount = db.prepare("SELECT COUNT(*) AS c FROM student").get().c;
-
- // One pass over the table counting non-NULLs for every column at once.
- const sums = columns
- .map((c) => `SUM(CASE WHEN "${c}" IS NOT NULL THEN 1 ELSE 0 END) AS "${c}"`)
- .join(", ");
- const nonNull = db.prepare(`SELECT ${sums} FROM student`).get();
-
- const sample = db
- .prepare(
- `SELECT * FROM student WHERE so_bao_danh LIKE '%${SAMPLE_SUFFIX}'
- ORDER BY so_bao_danh`,
- )
- .all();
-
- db.close();
-
- return {
- rowCount,
- columns,
- nonNull: Object.fromEntries(columns.map((c) => [c, Number(nonNull[c])])),
- sizeBytes: statSync(dbPath).size,
- sampleCount: sample.length,
- // Store the sample keyed by SBD so a later diff can report which student
- // and which field changed, not just that something did.
- sample: Object.fromEntries(sample.map((r) => [r.so_bao_danh, r])),
- };
-}
-
-const args = process.argv.slice(2);
-if (args.length === 0) {
- console.error("usage: db-stats.js = [...]");
- process.exit(2);
-}
-
-const out = {};
-for (const arg of args) {
- const idx = arg.indexOf("=");
- if (idx === -1) {
- console.error(`bad argument (expected label=path): ${arg}`);
- process.exit(2);
- }
- const label = arg.slice(0, idx);
- const path = arg.slice(idx + 1);
- process.stderr.write(`collecting ${label} from ${path}\n`);
- out[label] = collect(path);
-}
-
-process.stdout.write(JSON.stringify(out, null, 2) + "\n");
diff --git a/parser/scripts/verify-parity.js b/parser/scripts/verify-parity.js
deleted file mode 100644
index b2b6455..0000000
--- a/parser/scripts/verify-parity.js
+++ /dev/null
@@ -1,157 +0,0 @@
-#!/usr/bin/env node
-/**
- * Compare rebuilt databases against the pre-refactor parity baseline.
- *
- * The schema deliberately changed shape, so a whole-file hash is meaningless.
- * What must hold instead:
- *
- * 1. Row count per dataset is unchanged.
- * 2. Every column that existed before has the same non-NULL count.
- * 3. Every column newly added to a dataset has a non-NULL count of exactly 0.
- * This is the check that catches the union-regex risk — if the 16-pattern
- * map starts matching text the narrower per-year map ignored, it shows up
- * here rather than silently corrupting the dataset.
- * 4. A deterministic sample of students is identical field by field.
- *
- * Exits non-zero on any mismatch.
- *
- * Usage:
- * node verify-parity.js
- */
-
-import { readFileSync } from "node:fs";
-
-/**
- * Foreign-language scores the pre-refactor configs silently discarded.
- *
- * The 2016 config listed 12 subject regexes and the 2017 configs listed 14;
- * neither list was complete. Candidates could sit German, Japanese and Russian
- * in both exam years, so every one of these students previously ended up with
- * no foreign-language score at all.
- *
- * Unifying to the canonical 16 patterns recovers them. Verified real, not
- * spurious matches: across all four datasets every student holds either zero
- * or exactly one foreign language — never two — and each affected student had
- * all language columns NULL beforehand.
- *
- * These exact counts are approved. Any other newly-populated column, or any
- * drift in these numbers, still fails the gate.
- */
-const APPROVED_RECOVERY = {
- "2016": { tieng_nga: 182 },
- "2017": { tieng_duc: 93, tieng_nhat: 512 },
- "2017-old": { tieng_duc: 85, tieng_nhat: 484 },
- "2017-old2": { tieng_duc: 22, tieng_nhat: 313 },
-};
-
-const [baselinePath, currentPath] = process.argv.slice(2);
-if (!baselinePath || !currentPath) {
- console.error("usage: verify-parity.js ");
- process.exit(2);
-}
-
-const baseline = JSON.parse(readFileSync(baselinePath, "utf8"));
-const current = JSON.parse(readFileSync(currentPath, "utf8"));
-
-const failures = [];
-const notes = [];
-
-for (const dataset of Object.keys(baseline)) {
- const b = baseline[dataset];
- const c = current[dataset];
-
- if (!c) {
- failures.push(`${dataset}: missing from current stats`);
- continue;
- }
-
- // 1. Row count
- if (b.rowCount !== c.rowCount) {
- failures.push(
- `${dataset}: row count ${b.rowCount} → ${c.rowCount} (${c.rowCount - b.rowCount >= 0 ? "+" : ""}${c.rowCount - b.rowCount})`,
- );
- }
-
- // 2. Pre-existing columns keep their non-NULL counts
- for (const col of b.columns) {
- if (!(col in c.nonNull)) {
- failures.push(`${dataset}.${col}: column disappeared from schema`);
- continue;
- }
- if (b.nonNull[col] !== c.nonNull[col]) {
- failures.push(
- `${dataset}.${col}: non-NULL ${b.nonNull[col]} → ${c.nonNull[col]} (${c.nonNull[col] - b.nonNull[col] >= 0 ? "+" : ""}${c.nonNull[col] - b.nonNull[col]})`,
- );
- }
- }
-
- // 3. Newly added columns must be NULL — except the approved recoveries,
- // which must match their approved count exactly.
- const approved = APPROVED_RECOVERY[dataset] ?? {};
- const added = c.columns.filter((col) => !b.columns.includes(col));
- const recovered = [];
- for (const col of added) {
- const expected = approved[col] ?? 0;
- if (c.nonNull[col] !== expected) {
- failures.push(
- expected === 0
- ? `${dataset}.${col}: new column has ${c.nonNull[col]} non-NULL values, expected 0`
- : `${dataset}.${col}: recovered ${c.nonNull[col]} values, approved count is ${expected}`,
- );
- } else if (expected > 0) {
- recovered.push(`${col}=${expected}`);
- }
- }
- // An approved recovery that vanished means the union patterns regressed.
- for (const [col, expected] of Object.entries(approved)) {
- if (!added.includes(col)) {
- failures.push(
- `${dataset}.${col}: expected ${expected} recovered values but column is not new`,
- );
- }
- }
- if (added.length) {
- const nulls = added.length - recovered.length;
- notes.push(
- `${dataset}: +${added.length} new columns (${nulls} all-NULL as expected` +
- (recovered.length ? `, recovered ${recovered.join(", ")}` : "") +
- ")",
- );
- }
-
- // 4. Deterministic sample compared field by field
- if (b.sampleCount !== c.sampleCount) {
- failures.push(
- `${dataset}: sample size ${b.sampleCount} → ${c.sampleCount}`,
- );
- }
- for (const [sbd, bRow] of Object.entries(b.sample)) {
- const cRow = c.sample[sbd];
- if (!cRow) {
- failures.push(`${dataset}: sampled student ${sbd} missing after rebuild`);
- continue;
- }
- for (const [field, bVal] of Object.entries(bRow)) {
- if (cRow[field] !== bVal) {
- failures.push(
- `${dataset}: student ${sbd} field ${field}: ${JSON.stringify(bVal)} → ${JSON.stringify(cRow[field])}`,
- );
- }
- }
- }
-
- const sizeDelta = ((c.sizeBytes - b.sizeBytes) / b.sizeBytes) * 100;
- notes.push(
- `${dataset}: ${c.rowCount} rows, ${b.columns.length} → ${c.columns.length} cols, size ${sizeDelta >= 0 ? "+" : ""}${sizeDelta.toFixed(1)}%`,
- );
-}
-
-for (const n of notes) console.log(` ${n}`);
-
-if (failures.length) {
- console.error(`\nPARITY FAILED — ${failures.length} mismatch(es):\n`);
- for (const f of failures) console.error(` ✗ ${f}`);
- process.exit(1);
-}
-
-console.log("\nPARITY OK — row counts, per-column non-NULL counts and samples all match.");
diff --git a/parser/src/schema.rs b/parser/src/schema.rs
deleted file mode 100644
index 6eaf739..0000000
--- a/parser/src/schema.rs
+++ /dev/null
@@ -1,213 +0,0 @@
-//! Canonical `student` table definition — the single source of truth for the
-//! SQL shape of every dataset.
-//!
-//! Every dataset (2016, 2017, 2017-old, 2017-old2) is written into this same
-//! 22-column table. Columns a dataset has no data for bind NULL, which costs
-//! ~1 byte per row in SQLite's record header.
-//!
-//! Column provenance:
-//! ten_cum_thi, gioi_tinh, tieng_duc, tieng_nhat → 2016 only
-//! khtn, khxh, gdcd, tieng_nga → 2017 datasets only
-//! everything else → both
-//!
-//! Before this module existed the DDL, the INSERT statement and the subject
-//! regex table were duplicated across four TOML configs, which is how the 2016
-//! and 2017 schemas drifted apart in the first place. The configs now carry only
-//! per-dataset parse rules.
-// ---------------------------------------------------------------------------
-// DDL
-// ---------------------------------------------------------------------------
-
-/// Executed verbatim after the output database is (re)created.
-///
-/// `idx_ten_cum_thi` is partial so it holds zero entries on the three 2017
-/// datasets — where the column is always NULL — while staying fully useful for
-/// the 2016 cluster-grouping queries.
-pub const DDL: &str = "
-CREATE TABLE student (
- so_bao_danh TEXT PRIMARY KEY,
- ho_ten TEXT NOT NULL,
- ho_ten_ascii TEXT NOT NULL,
- ngay_sinh TEXT,
- ten_cum_thi TEXT,
- gioi_tinh TEXT,
- toan REAL,
- ngu_van REAL,
- vat_ly REAL,
- hoa_hoc REAL,
- sinh_hoc REAL,
- khtn REAL,
- lich_su REAL,
- dia_ly REAL,
- gdcd REAL,
- khxh REAL,
- tieng_anh REAL,
- tieng_phap REAL,
- tieng_nga REAL,
- tieng_duc REAL,
- tieng_nhat REAL,
- tieng_trung REAL
-);
-CREATE INDEX idx_ho_ten ON student(ho_ten);
-CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
-CREATE INDEX idx_ten_cum_thi ON student(ten_cum_thi) WHERE ten_cum_thi IS NOT NULL;
-";
-
-// ---------------------------------------------------------------------------
-// Column order
-// ---------------------------------------------------------------------------
-
-/// Identity columns, in INSERT parameter order.
-pub const IDENTITY_FIELDS: &[&str] = &[
- "so_bao_danh",
- "ho_ten",
- "ho_ten_ascii",
- "ngay_sinh",
- "ten_cum_thi",
- "gioi_tinh",
-];
-
-/// Subject columns, in INSERT parameter order. Bound as NULL when a row has no
-/// score for that subject.
-pub const SCORE_FIELDS: &[&str] = &[
- "toan",
- "ngu_van",
- "vat_ly",
- "hoa_hoc",
- "sinh_hoc",
- "khtn",
- "lich_su",
- "dia_ly",
- "gdcd",
- "khxh",
- "tieng_anh",
- "tieng_phap",
- "tieng_nga",
- "tieng_duc",
- "tieng_nhat",
- "tieng_trung",
-];
-
-/// Total bound parameters per row.
-pub const PARAM_COUNT: usize = IDENTITY_FIELDS.len() + SCORE_FIELDS.len();
-
-// ---------------------------------------------------------------------------
-// INSERT
-// ---------------------------------------------------------------------------
-
-/// Positional INSERT matching IDENTITY_FIELDS followed by SCORE_FIELDS.
-///
-/// `OR REPLACE` preserves the pre-existing behaviour where a repeated SBD
-/// overwrites the earlier row rather than aborting the transaction.
-pub const INSERT_SQL: &str = "
-INSERT OR REPLACE INTO student
- (so_bao_danh, ho_ten, ho_ten_ascii, ngay_sinh, ten_cum_thi, gioi_tinh,
- toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
- lich_su, dia_ly, gdcd, khxh,
- tieng_anh, tieng_phap, tieng_nga, tieng_duc, tieng_nhat, tieng_trung)
-VALUES
- (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-";
-
-// ---------------------------------------------------------------------------
-// Subject score patterns
-// ---------------------------------------------------------------------------
-
-/// Regex per subject, applied to the DIEM_THI cell text.
-///
-/// Every pattern runs against every dataset. A subject absent from a given exam
-/// year simply never matches and stays NULL — 2016 source files contain no
-/// "KHTN:" or "Tiếng Nga:" tokens, and 2017 files contain no "Tiếng Đức:" or
-/// "Tiếng Nhật:". The parity check asserts those counts are exactly zero rather
-/// than assuming it.
-///
-/// Order here is irrelevant (matching is by name); SCORE_FIELDS fixes the
-/// INSERT order.
-pub const SCORE_PATTERNS: &[(&str, &str)] = &[
- ("toan", r"Toán:\s*(\d+(?:\.\d+)?)"),
- ("ngu_van", r"Ngữ văn:\s*(\d+(?:\.\d+)?)"),
- ("vat_ly", r"Vật lí:\s*(\d+(?:\.\d+)?)"),
- ("hoa_hoc", r"Hóa học:\s*(\d+(?:\.\d+)?)"),
- ("sinh_hoc", r"Sinh học:\s*(\d+(?:\.\d+)?)"),
- ("khtn", r"KHTN:\s*(\d+(?:\.\d+)?)"),
- ("lich_su", r"Lịch sử:\s*(\d+(?:\.\d+)?)"),
- ("dia_ly", r"Địa lí:\s*(\d+(?:\.\d+)?)"),
- ("gdcd", r"GDCD:\s*(\d+(?:\.\d+)?)"),
- ("khxh", r"KHXH:\s*(\d+(?:\.\d+)?)"),
- ("tieng_anh", r"Tiếng Anh:\s*(\d+(?:\.\d+)?)"),
- ("tieng_phap", r"Tiếng Pháp:\s*(\d+(?:\.\d+)?)"),
- ("tieng_nga", r"Tiếng Nga:\s*(\d+(?:\.\d+)?)"),
- ("tieng_duc", r"Tiếng Đức:\s*(\d+(?:\.\d+)?)"),
- ("tieng_nhat", r"Tiếng Nhật:\s*(\d+(?:\.\d+)?)"),
- ("tieng_trung", r"Tiếng Trung:\s*(\d+(?:\.\d+)?)"),
-];
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- /// The INSERT placeholder count, the named column list and the field
- /// constants must agree, or rows silently land in the wrong columns.
- #[test]
- fn insert_matches_field_order() {
- assert_eq!(PARAM_COUNT, 22);
- assert_eq!(INSERT_SQL.matches('?').count(), PARAM_COUNT);
-
- let named = INSERT_SQL
- .split_once('(')
- .and_then(|(_, rest)| rest.split_once(')'))
- .map(|(cols, _)| cols)
- .expect("INSERT must contain a column list");
-
- let listed: Vec<&str> = named
- .split(',')
- .map(str::trim)
- .filter(|s| !s.is_empty())
- .collect();
-
- let expected: Vec<&str> = IDENTITY_FIELDS
- .iter()
- .chain(SCORE_FIELDS.iter())
- .copied()
- .collect();
-
- assert_eq!(listed, expected);
- }
-
- /// Every subject column must have a pattern and vice versa.
- #[test]
- fn score_patterns_cover_score_fields() {
- assert_eq!(SCORE_PATTERNS.len(), SCORE_FIELDS.len());
- for (field, _) in SCORE_PATTERNS {
- assert!(
- SCORE_FIELDS.contains(field),
- "pattern {field} has no column"
- );
- }
- for field in SCORE_FIELDS {
- assert!(
- SCORE_PATTERNS.iter().any(|(f, _)| f == field),
- "column {field} has no pattern"
- );
- }
- }
-
- /// Every column named in the DDL must be bound by the INSERT.
- #[test]
- fn ddl_columns_match_insert() {
- for field in IDENTITY_FIELDS.iter().chain(SCORE_FIELDS.iter()) {
- assert!(DDL.contains(field), "DDL missing column {field}");
- }
- }
-
- #[test]
- fn score_patterns_compile() {
- for (field, src) in SCORE_PATTERNS {
- regex::Regex::new(src).unwrap_or_else(|e| panic!("{field}: {e}"));
- }
- }
-}
diff --git a/plans/260813-0956-unify-frontend-standard-schema/phase-01-standard-schema-and-unified-parser.md b/plans/260813-0956-unify-frontend-standard-schema/phase-01-standard-schema-and-unified-parser.md
deleted file mode 100644
index 761f9f5..0000000
--- a/plans/260813-0956-unify-frontend-standard-schema/phase-01-standard-schema-and-unified-parser.md
+++ /dev/null
@@ -1,170 +0,0 @@
----
-phase: 1
-title: "Standard schema and unified parser"
-status: completed
-priority: P1
-dependencies: []
-effort: ""
----
-
-# Phase 1: Standard schema and unified parser
-
-## Overview
-
-Move the SQL schema out of the four TOML configs into one Rust module, widen it
-to the 22-column union, and prove the four rebuilt databases still match their
-current contents. Done in place under the existing `2016/`/`2017/` layout so
-parity is measurable before anything moves.
-
-## Context
-
-`2016/tools/xlsxread` and `2017/tools/xlsxread` are the same crate. Verified by
-`diff -rq`: `cli.rs`, `error.rs`, `reader.rs` are byte-identical; `audit.rs`,
-`config.rs`, `lib.rs`, `main.rs`, `transform.rs`, `writer.rs` differ only where
-2016 adds a branch; `format_detect_2016.rs` (549 lines) exists only in 2016.
-The 2016 crate is therefore the base to keep — it already builds 2017 datasets
-(its `configs/` holds 2017 fixtures used by `tests/golden.rs`).
-
-Current per-dataset schema divergence:
-
-| Column group | 2016 | 2017 / old / old2 |
-|---|---|---|
-| `ten_cum_thi`, `gioi_tinh` | present | absent |
-| `khtn`, `khxh`, `gdcd`, `tieng_nga` | absent | present |
-| `tieng_duc`, `tieng_nhat` | present | absent |
-
-## Requirements
-
-**Functional**
-- One DDL, one INSERT, one ordered subject list, one regex map — used by all four datasets
-- Configs keep only genuinely per-dataset settings
-- Rebuilt DBs preserve every value currently produced
-
-**Non-functional**
-- No measurable build-time regression (currently a few minutes per dataset)
-- DB size growth from the added NULL columns stays under ~2%
-
-## Architecture
-
-**New `parser/src/schema.rs`** (written into `2016/tools/xlsxread/src/` this
-phase; relocated in Phase 2) exports:
-
-- `pub const DDL: &str` — the 22-column CREATE TABLE + 3 indexes
-- `pub const INSERT_SQL: &str` — 22 positional placeholders
-- `pub const IDENTITY_FIELDS: &[&str]` — 6 identity columns in INSERT order
-- `pub const SCORE_FIELDS: &[&str]` — 16 subject columns in INSERT order
-- `pub fn score_patterns() -> Vec<(&'static str, &'static str)>` — the union of
- all 16 subject regexes (2016's 12 ∪ 2017's 14; both sets share 10)
-
-**`writer.rs` collapses.** `SCORE_FIELDS_2016`, `SCORE_FIELDS_2017`, the
-`SCORE_FIELDS` alias, and `insert_row_2016` all disappear. A single `insert_row`
-binds identity fields then subject fields, always 22 params.
-
-**`config.rs` shrinks.** `[schema]` and `[insert]` sections are removed from
-`DatasetConfig`; `[scores]` becomes optional and unused (delete the field once
-no config sets it). `columns` stays `Option` — the 2016 detection path
-does not use it.
-
-**`main.rs`** keeps the `run_build_2016` / `run_build_standard` split; both now
-call the same `insert_row` with `schema::SCORE_FIELDS`.
-
-### Union regex risk — RESOLVED, and it found a real bug
-
-Applying all 16 patterns to every dataset means a source cell containing a
-subject the old config never looked for now populates a column it previously
-could not.
-
-**Outcome: the gate fired, and the matches were real data, not false positives.**
-
-The pre-refactor configs listed 12 subject regexes (2016) and 14 (2017). Neither
-list was complete — Vietnamese candidates could sit German, Japanese and Russian
-in *both* exam years. Every affected student previously ended up with **no**
-foreign-language score at all. Unifying to 16 patterns recovered 1,691 scores:
-
-| Dataset | Recovered |
-|---|---|
-| 2016 | 182 × `tieng_nga` |
-| 2017 | 93 × `tieng_duc`, 512 × `tieng_nhat` |
-| 2017-old | 85 × `tieng_duc`, 484 × `tieng_nhat` |
-| 2017-old2 | 22 × `tieng_duc`, 313 × `tieng_nhat` |
-
-Verified genuine, not spurious:
-- Across all four datasets every student holds **zero or exactly one** foreign
- language — never two. So the new columns duplicate nothing.
-- Each affected student had *all* language columns NULL beforehand
- (e.g. SBD `01003198`: all NULL → `tieng_duc = 8`).
-- Counts track dataset size consistently across the three 2017 generations
- (93/85/22 German, 512/484/313 Japanese).
-- Score ranges and distributions are ordinary exam values (0–10).
-
-Row counts, all 18 pre-existing per-column non-NULL counts, and the
-deterministic student samples were **identical** — nothing was lost.
-
-Accepted as a data-quality fix. The earlier guidance to add a per-config subject
-allowlist assumed spurious matches and does **not** apply; suppressing these
-would knowingly discard real scores. The exact counts are now encoded as
-`APPROVED_RECOVERY` in `verify-parity.js`, so any *other* newly-populated column
-— or any drift in these numbers — still fails the gate.
-
-## Related Code Files
-
-- Create: `2016/tools/xlsxread/src/schema.rs`
-- Modify: `2016/tools/xlsxread/src/writer.rs` (drop dual insert paths + field lists)
-- Modify: `2016/tools/xlsxread/src/config.rs` (drop `[schema]`/`[insert]`/`[scores]`)
-- Modify: `2016/tools/xlsxread/src/main.rs` (single insert call site per path)
-- Modify: `2016/tools/xlsxread/src/lib.rs` (declare `schema` module)
-- Modify: `2016/tools/xlsxread/src/transform.rs` (source patterns from `schema`)
-- Modify: `2016/tools/xlsxread/tests/golden.rs` (assert against canonical schema)
-- Create: `2016/tools/xlsxread/configs/thptqg2017-data-old2.toml` (copy from 2017 crate)
-- Modify: all 4 configs — strip DDL/INSERT/scores down to parse rules
-- Delete (Phase 2): `2017/tools/`
-
-## Implementation Steps
-
-1. **Capture the baseline first.** Build all four DBs with the *current* code and
- record, per dataset: `SELECT COUNT(*) FROM student`, and for each column
- `SUM(col IS NOT NULL)`. Store as `plans/reports/parser-parity-baseline.json`.
- Nothing else in this plan is verifiable without this artifact.
-2. Copy `thptqg2017-data-old2.toml` into the 2016 crate's `configs/`.
-3. Write `schema.rs` with DDL, INSERT, field orders, and the union regex map.
-4. Rewrite `writer.rs` to a single `insert_row`; delete `insert_row_2016` and
- the three score-field constants.
-5. Strip `[schema]`, `[insert]`, `[scores]` from all four configs; update
- `config.rs` structs to match. Each config should end at ~15–20 lines.
-6. Update `main.rs` and `transform.rs` call sites.
-7. Update `tests/golden.rs` to assert the canonical column set.
-8. `cargo test` — golden tests must pass.
-9. Rebuild all four DBs; regenerate the same stats and diff against the baseline.
-
-## Tests / Validation
-
-- `cargo test` — 63 tests (55 unit + 8 golden)
-- `cargo clippy --all-targets -- -D warnings`
-- Row count per dataset equals baseline exactly
-- Per-column non-NULL count equals baseline for every previously-existing column
-- Newly-added columns read 0 non-NULL, except the approved recoveries above
-- DB file size within 2% of baseline
-
-**Note on the clippy gate:** it was already red before this phase — measured at
-**8 errors on the branch base**. This phase's changes introduced none. The
-pre-existing lints were cleared in a separate commit so the gate is genuinely
-green from here on.
-
-## Success Criteria
-
-- [x] `schema.rs` is the only place DDL/INSERT/subject-order/regexes are written
-- [x] All 4 configs under 35 lines, containing no SQL
-- [x] `writer.rs` has exactly one insert function
-- [x] `cargo test` (63 passing) and `cargo clippy --all-targets -D warnings` green
-- [x] All 4 DBs match baseline row counts and per-column non-NULL counts
-- [x] Baseline JSON committed under `plans/reports/`
-- [x] Config parsing rejects leftover SQL sections (`deny_unknown_fields`)
-
-## Risk Assessment
-
-| Risk | Mitigation |
-|---|---|
-| Union regexes populate unexpected columns | Baseline diff catches it; fall back to per-config subject allowlist |
-| INSERT param order drifts from DDL order | Single `IDENTITY_FIELDS`/`SCORE_FIELDS` source drives both; golden test asserts round-trip |
-| Rebuilding 419 MB of source is slow | Run once per verification pass, not per edit; iterate against golden fixtures |
-| Baseline skipped under time pressure | Phase 1 is unverifiable without it — treat step 1 as blocking |
diff --git a/plans/260813-0956-unify-frontend-standard-schema/phase-02-repo-restructure.md b/plans/260813-0956-unify-frontend-standard-schema/phase-02-repo-restructure.md
deleted file mode 100644
index 1665226..0000000
--- a/plans/260813-0956-unify-frontend-standard-schema/phase-02-repo-restructure.md
+++ /dev/null
@@ -1,149 +0,0 @@
----
-phase: 2
-title: "Repo restructure"
-status: completed
-priority: P1
-dependencies: [1]
-effort: ""
----
-
-# Phase 2: Repo restructure
-
-## Overview
-
-Move files into the target layout and migrate pnpm → npm. Relocation plus
-package-manager swap — no application logic changes. Kept as its own commit so
-the 419 MB data move is trivially revertable and reviewable separately from
-behavior changes.
-
-## Requirements
-
-- All moves via `git mv` so blobs are reused and history follows
-- Working tree builds after the move (paths updated, nothing dangling)
-- Repository size does not grow
-- npm is the only package manager; `package-lock.json` committed, no pnpm files remain
-
-## Architecture
-
-Git stores blobs content-addressed, so renaming 299 tracked data files
-(~419 MB working-tree) adds no new objects. The cost is local I/O and one large
-tree rewrite, not repository growth.
-
-Data directory sizes being moved: `2016/data` 62 MB, `2017/data` 286 MB,
-`2017/data-old` 39 MB, `2017/data-old2` 32 MB.
-
-### pnpm → npm
-
-A single package with 3 runtime and 9 dev dependencies, no workspace. pnpm's
-advantages (strict resolution, shared store) buy nothing at this size, and two
-pieces of scaffolding disappear with it:
-
-- `pnpm-workspace.yaml` exists **only** to whitelist `better-sqlite3`'s native
- build (`allowBuilds`). npm runs postinstall by default, so the file has no npm
- equivalent — it is deleted, not translated. Verified: this is the file's
- entire content in both projects.
-- The `pnpm/action-setup@v4` CI step and `cache: 'pnpm'` both drop out.
-
-Lockfiles cannot be converted; `package-lock.json` is generated fresh from
-`package.json`. Migration direction is safe: pnpm's strict `node_modules` layout
-forbids phantom dependencies, so anything that resolved under pnpm also resolves
-under npm's flat tree. The reverse would not hold.
-
-Latent issue surfaced while checking: `2017/scripts/diff-datasets.js` imports
-`better-sqlite3`, which is **not declared in any `package.json`** — that script
-cannot run today without an ad-hoc install. Do not add the dependency; Phase 5
-uses the built-in `node:sqlite` instead (verified working on Node 24, no flag).
-Either port `diff-datasets.js` to `node:sqlite` in this phase or leave it broken
-exactly as it is today and note it — do not silently half-fix it.
-
-## Related Code Files
-
-**Moves**
-
-| From | To |
-|---|---|
-| `2016/tools/xlsxread/` | `parser/` |
-| `2016/tools/xlsxread/configs/thptqg2016-data.toml` | `parser/configs/2016.toml` |
-| `2017/tools/xlsxread/configs/thptqg2017-data.toml` | `parser/configs/2017.toml` |
-| `2017/tools/xlsxread/configs/thptqg2017-data-old.toml` | `parser/configs/2017-old.toml` |
-| `2017/tools/xlsxread/configs/thptqg2017-data-old2.toml` | `parser/configs/2017-old2.toml` |
-| `2017/scripts/` | `parser/scripts/` |
-| `2016/data/` | `data/2016/` |
-| `2017/data/` | `data/2017/` |
-| `2017/data-old/` | `data/2017-old/` |
-| `2017/data-old2/` | `data/2017-old2/` |
-| `2017/src/` | `src/` |
-| `2017/index.html` | `index.html` (overwrites the old static landing page) |
-| `2017/package.json`, `eslint.config.js`, `vite.config.js` | repo root |
-| `2016/docs/*`, `2017/docs/*` | `docs/` |
-| `2017/LICENSE` | `LICENSE` |
-
-The old root `index.html` (the static hub) is **not moved** — it becomes
-`src/components/hub.jsx` in Phase 3. Read it before deleting; its four links,
-candidate counts, and Vietnamese copy are the source material for that
-component.
-
-**Deletes**
-- `2016/` and `2017/` directories entirely (after moves)
-- `2016/src/` — superseded by `src/` (its unique columns and SQL presets are ported in Phase 3)
-- `2016/tools/xlsxread/configs/thptqg2017-*.toml` — test fixtures, superseded by real configs
-- `2016/pnpm-lock.yaml`, `2017/pnpm-lock.yaml`, both `pnpm-workspace.yaml`
-- Duplicate `2016/package.json`, `2016/eslint.config.js`, `2016/vite.config.js`, `2016/LICENSE`
-- Root `index.html` (static hub) — only after its content is captured for `hub.jsx`
-
-**Merges**
-- `.gitignore` — one root file. 2016's is the verbose GitHub Node template,
- 2017's is terse and accurate. Take 2017's as the base, add `parser/target/`,
- `.build/`, and `dist/`.
-- `package.json` — root file keeps 2017's dependency set (identical to 2016's).
- Drop the `"packageManager": "pnpm@11.1.1"` field.
-
-## Implementation Steps
-
-1. Branch: `refactor/unify-frontend-and-schema`.
-2. Copy the old root `index.html` content somewhere durable for Phase 3
- (`plans/reports/` or the phase-03 file itself) — it is the hub's source copy.
-3. `git mv 2017/src src`, then the 2017 root config files, then
- `git mv 2017/index.html index.html`.
-4. `git mv 2016/tools/xlsxread parser`, then rename the four configs.
-5. `git mv 2017/scripts parser/scripts`.
-6. Move the four data directories.
-7. Merge docs into `docs/`, prefixing 2016-specific filenames where they collide
- (`deployment-guide.md` and `system-architecture.md` exist in both — read both
- before merging; they describe different pipelines).
-8. Delete the emptied `2016/` and `2017/` trees plus both `pnpm-workspace.yaml`
- and both `pnpm-lock.yaml`.
-9. Write the merged root `.gitignore`.
-10. Drop `"packageManager"` from `package.json`; run `npm install` to generate
- `package-lock.json`; commit the lockfile.
-11. Fix paths inside moved files: `parser/Cargo.toml` package name/paths,
- config `--input`/`--output` defaults, `parser/scripts/*.js` relative paths.
-12. Replace `pnpm` with `npm run` in every `package.json` script body.
-13. `cargo test` from `parser/` — must still be green.
-
-## Tests / Validation
-
-- `cargo test --manifest-path parser/Cargo.toml`
-- `npm ci && npm run lint` at root — `npm ci` proves the lockfile is in sync
-- `git status` shows renames (R), not delete+add pairs
-- No file outside `plans/` still references `2016/tools`, `2017/tools`,
- `2017/src`, `2017/data`, or `pnpm` — grep to confirm
-
-## Success Criteria
-
-- [x] Target layout matches `plan.md` exactly
-- [x] `2016/` and `2017/` no longer exist
-- [x] Old hub page content captured before deletion
-- [x] Git reports renames, repository size unchanged
-- [x] `package-lock.json` committed; `npm ci` succeeds; zero pnpm files remain
-- [x] `cargo test` green from the new location
-
-## Risk Assessment
-
-| Risk | Mitigation |
-|---|---|
-| Old hub page deleted before its copy is captured | Step 2 runs before any move; content also recoverable from git history |
-| Docs collide silently on merge | Read both copies before merging; two same-named files describe different pipelines |
-| Git records delete+add instead of rename | Use `git mv`; verify with `git status` before commit |
-| Stale path references in scripts/CI | Grep sweep in validation; Phase 4 rewrites the workflow |
-| Fresh npm lockfile resolves different transitive versions than pnpm did | All deps are caret-ranged and already floating; `npm run lint` + the Phase 4 local build run are the check |
diff --git a/plans/260813-0956-unify-frontend-standard-schema/phase-03-unified-frontend-and-dataset-registry.md b/plans/260813-0956-unify-frontend-standard-schema/phase-03-unified-frontend-and-dataset-registry.md
deleted file mode 100644
index e36e839..0000000
--- a/plans/260813-0956-unify-frontend-standard-schema/phase-03-unified-frontend-and-dataset-registry.md
+++ /dev/null
@@ -1,249 +0,0 @@
----
-phase: 3
-title: "Unified frontend and dataset registry"
-status: completed
-priority: P1
-dependencies: [2]
-effort: ""
----
-
-# Phase 3: Unified frontend and dataset registry
-
-## Overview
-
-Make the single 2017-derived frontend serve every page on the site: the four
-dataset views plus the `/thptqg/` hub. Three kinds of work — port 2016's only
-unique feature (cluster + gender columns) into the shared components, extract
-everything genuinely per-dataset into a runtime registry, and add a small
-pathname router with a hub route.
-
-## Context — what actually differs
-
-The 2017 frontend is a superset in every respect except two columns. Verified by
-reading both trees:
-
-**2017 has, 2016 lacks:** debounced live search with input-mode hints
-(`search-form.jsx`, 127 vs 46 lines), `?q=` URL deep links, `student-detail.jsx`
-(183 lines, single-result view), `lib/admission-blocks.js` (49 blocks +
-`scoreTier` ladder), all-NULL column hiding, `/` and Ctrl+Enter shortcuts,
-footer total count, load progress bar, richer SQL presets (335 vs 219 lines).
-
-**2016 has, 2017 lacks:** `ten_cum_thi` and `gioi_tinh` table columns with a
-`cumthi-cell` title tooltip, and a simpler 3-tier `scoreClass` (superseded by
-2017's 6-tier `scoreTier`).
-
-## Requirements
-
-**Functional**
-- 2016 site keeps cluster + gender columns; 2017 sites must not show them
-- 2016 site gains every 2017 feature listed above
-- Per-dataset chrome (title, subtitle, source, DB size, search examples, SQL presets) is data, not code
-- Admission-block computation works for both exam years without branching
-- `/thptqg/` renders a hub listing the four datasets; no DB is fetched there
-- Deep links (`?q=`) keep working on every dataset route
-
-**Non-functional**
-- Dataset identity resolves from `location.pathname` — no fetch, no env plumbing
-- No routing library; five static routes do not justify a dependency
-- Hub route must not pull the 47 MB DB into its critical path
-
-## Architecture
-
-### `src/datasets.js` — the registry
-
-```js
-export const DATASETS = [
- {
- id: "2016", // === URL segment === data dir === config === db file
- dbSizeMb: 48,
- title: "Tra cứu điểm thi THPT Quốc gia 2016",
- subtitle: "Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh",
- source: "Bộ GD&ĐT",
- examples: ["", "Nguyễn Minh Tiến"],
- presets: PRESETS_2016, // from 2016/src/components/custom-query.jsx
- blocks: BLOCKS_2016, // see admission blocks below
- },
- { id: "2017", /* presets: PRESETS_2017 */ },
- { id: "2017-old", /* same presets as 2017, own label */ },
- { id: "2017-old2", /* same presets as 2017, own label */ },
-];
-
-export const pathOf = (d) => `${import.meta.env.BASE_URL}${d.id}/`;
-export const dbOf = (d) => `${import.meta.env.BASE_URL}db/${d.id}.db.gz`;
-```
-
-Plain runtime data — no `import.meta.env.VITE_DATASET` inlining, no build
-variants. All four entries ship in the one bundle; the preset SQL totals a few
-KB gzipped. Because URLs are flat, path and DB URL are *derived* from `id`
-rather than stored, so a dataset cannot be misconfigured into pointing at the
-wrong database.
-
-### `src/router.js`
-
-Flat URLs make this an exact match on a single segment — the segment **is** the
-dataset ID:
-
-```js
-export function resolveRoute(pathname = location.pathname) {
- const seg = pathname
- .slice(import.meta.env.BASE_URL.length) // strip "/thptqg/"
- .replace(/\/$/, "");
- return DATASETS.find((d) => d.id === seg) ?? null; // null → hub
-}
-```
-
-No prefix sorting, no ambiguity between `2017` and `2017-old` — that entire
-class of bug is designed out by the flat scheme. No `react-router`.
-
-### Legacy path redirects
-
-The two old nested URLs are kept alive by a small map, so existing links and
-bookmarks resolve instead of 404ing:
-
-```js
-const LEGACY = { "2017/old": "2017-old", "2017/old2": "2017-old2" };
-```
-
-On a legacy match, `history.replaceState` to the canonical flat URL **preserving
-`location.search`** (the `?q=` deep link must survive), then resolve normally.
-Phase 4 emits `index.html` at both legacy paths so Pages serves them at all.
-
-Droppable if you'd rather let the old URLs 404 — it costs ~5 lines and two file
-copies, and nothing else in the plan depends on it.
-
-### `src/components/hub.jsx`
-
-Renders the four dataset links from `DATASETS` via `pathOf()`. Content ported
-from the old static root `index.html` (captured in Phase 2 step 2): heading, the
-sql.js one-liner explanation, candidate counts, and the "phiên bản cũ" grouping
-of the two 2017 variants. Styled with the app's existing CSS instead of the old
-inline `