online Minecraft written book viewer

feat: initial commit

+6009
+3
.gitignore
··· 1 + /target 2 + 3 + realm.nbt
+1897
Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "adler2" 7 + version = "2.0.1" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 10 + 11 + [[package]] 12 + name = "ahash" 13 + version = "0.8.12" 14 + source = "registry+https://github.com/rust-lang/crates.io-index" 15 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 16 + dependencies = [ 17 + "cfg-if", 18 + "getrandom", 19 + "once_cell", 20 + "version_check", 21 + "zerocopy", 22 + ] 23 + 24 + [[package]] 25 + name = "alloc-no-stdlib" 26 + version = "2.0.4" 27 + source = "registry+https://github.com/rust-lang/crates.io-index" 28 + checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" 29 + 30 + [[package]] 31 + name = "alloc-stdlib" 32 + version = "0.2.2" 33 + source = "registry+https://github.com/rust-lang/crates.io-index" 34 + checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" 35 + dependencies = [ 36 + "alloc-no-stdlib", 37 + ] 38 + 39 + [[package]] 40 + name = "allocator-api2" 41 + version = "0.2.21" 42 + source = "registry+https://github.com/rust-lang/crates.io-index" 43 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 44 + 45 + [[package]] 46 + name = "android_system_properties" 47 + version = "0.1.5" 48 + source = "registry+https://github.com/rust-lang/crates.io-index" 49 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 50 + dependencies = [ 51 + "libc", 52 + ] 53 + 54 + [[package]] 55 + name = "anstream" 56 + version = "0.6.21" 57 + source = "registry+https://github.com/rust-lang/crates.io-index" 58 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 59 + dependencies = [ 60 + "anstyle", 61 + "anstyle-parse", 62 + "anstyle-query", 63 + "anstyle-wincon", 64 + "colorchoice", 65 + "is_terminal_polyfill", 66 + "utf8parse", 67 + ] 68 + 69 + [[package]] 70 + name = "anstyle" 71 + version = "1.0.13" 72 + source = "registry+https://github.com/rust-lang/crates.io-index" 73 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 74 + 75 + [[package]] 76 + name = "anstyle-parse" 77 + version = "0.2.7" 78 + source = "registry+https://github.com/rust-lang/crates.io-index" 79 + checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 80 + dependencies = [ 81 + "utf8parse", 82 + ] 83 + 84 + [[package]] 85 + name = "anstyle-query" 86 + version = "1.1.5" 87 + source = "registry+https://github.com/rust-lang/crates.io-index" 88 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 89 + dependencies = [ 90 + "windows-sys 0.61.2", 91 + ] 92 + 93 + [[package]] 94 + name = "anstyle-wincon" 95 + version = "3.0.11" 96 + source = "registry+https://github.com/rust-lang/crates.io-index" 97 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 98 + dependencies = [ 99 + "anstyle", 100 + "once_cell_polyfill", 101 + "windows-sys 0.61.2", 102 + ] 103 + 104 + [[package]] 105 + name = "anyhow" 106 + version = "1.0.102" 107 + source = "registry+https://github.com/rust-lang/crates.io-index" 108 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 109 + 110 + [[package]] 111 + name = "askama" 112 + version = "0.15.4" 113 + source = "registry+https://github.com/rust-lang/crates.io-index" 114 + checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57" 115 + dependencies = [ 116 + "askama_macros", 117 + "itoa", 118 + "percent-encoding", 119 + "serde", 120 + "serde_json", 121 + ] 122 + 123 + [[package]] 124 + name = "askama_derive" 125 + version = "0.15.4" 126 + source = "registry+https://github.com/rust-lang/crates.io-index" 127 + checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37" 128 + dependencies = [ 129 + "askama_parser", 130 + "basic-toml", 131 + "memchr", 132 + "proc-macro2", 133 + "quote", 134 + "rustc-hash", 135 + "serde", 136 + "serde_derive", 137 + "syn", 138 + ] 139 + 140 + [[package]] 141 + name = "askama_macros" 142 + version = "0.15.4" 143 + source = "registry+https://github.com/rust-lang/crates.io-index" 144 + checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b" 145 + dependencies = [ 146 + "askama_derive", 147 + ] 148 + 149 + [[package]] 150 + name = "askama_parser" 151 + version = "0.15.4" 152 + source = "registry+https://github.com/rust-lang/crates.io-index" 153 + checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c" 154 + dependencies = [ 155 + "rustc-hash", 156 + "serde", 157 + "serde_derive", 158 + "unicode-ident", 159 + "winnow", 160 + ] 161 + 162 + [[package]] 163 + name = "askama_web" 164 + version = "0.15.1" 165 + source = "registry+https://github.com/rust-lang/crates.io-index" 166 + checksum = "5911a65ac3916ef133167a855d52978f9fbf54680a093e0ef29e20b7e94a4523" 167 + dependencies = [ 168 + "askama", 169 + "askama_web_derive", 170 + "axum-core", 171 + "bytes", 172 + "http", 173 + "tracing", 174 + ] 175 + 176 + [[package]] 177 + name = "askama_web_derive" 178 + version = "0.2.0" 179 + source = "registry+https://github.com/rust-lang/crates.io-index" 180 + checksum = "9767c17d33a63daf6da5872ffaf2ab0c289cd73ce7ed4f41d5ddf9149c004873" 181 + dependencies = [ 182 + "proc-macro2", 183 + "quote", 184 + "syn", 185 + ] 186 + 187 + [[package]] 188 + name = "async-compression" 189 + version = "0.4.41" 190 + source = "registry+https://github.com/rust-lang/crates.io-index" 191 + checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" 192 + dependencies = [ 193 + "compression-codecs", 194 + "compression-core", 195 + "pin-project-lite", 196 + "tokio", 197 + ] 198 + 199 + [[package]] 200 + name = "atomic-waker" 201 + version = "1.1.2" 202 + source = "registry+https://github.com/rust-lang/crates.io-index" 203 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 204 + 205 + [[package]] 206 + name = "autocfg" 207 + version = "1.5.0" 208 + source = "registry+https://github.com/rust-lang/crates.io-index" 209 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 210 + 211 + [[package]] 212 + name = "axum" 213 + version = "0.8.8" 214 + source = "registry+https://github.com/rust-lang/crates.io-index" 215 + checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" 216 + dependencies = [ 217 + "axum-core", 218 + "bytes", 219 + "form_urlencoded", 220 + "futures-util", 221 + "http", 222 + "http-body", 223 + "http-body-util", 224 + "hyper", 225 + "hyper-util", 226 + "itoa", 227 + "matchit", 228 + "memchr", 229 + "mime", 230 + "percent-encoding", 231 + "pin-project-lite", 232 + "serde_core", 233 + "serde_json", 234 + "serde_path_to_error", 235 + "serde_urlencoded", 236 + "sync_wrapper", 237 + "tokio", 238 + "tower", 239 + "tower-layer", 240 + "tower-service", 241 + "tracing", 242 + ] 243 + 244 + [[package]] 245 + name = "axum-core" 246 + version = "0.5.6" 247 + source = "registry+https://github.com/rust-lang/crates.io-index" 248 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" 249 + dependencies = [ 250 + "bytes", 251 + "futures-core", 252 + "http", 253 + "http-body", 254 + "http-body-util", 255 + "mime", 256 + "pin-project-lite", 257 + "sync_wrapper", 258 + "tower-layer", 259 + "tower-service", 260 + "tracing", 261 + ] 262 + 263 + [[package]] 264 + name = "base64" 265 + version = "0.22.1" 266 + source = "registry+https://github.com/rust-lang/crates.io-index" 267 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 268 + 269 + [[package]] 270 + name = "basic-toml" 271 + version = "0.1.10" 272 + source = "registry+https://github.com/rust-lang/crates.io-index" 273 + checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" 274 + dependencies = [ 275 + "serde", 276 + ] 277 + 278 + [[package]] 279 + name = "bitflags" 280 + version = "2.11.0" 281 + source = "registry+https://github.com/rust-lang/crates.io-index" 282 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 283 + 284 + [[package]] 285 + name = "block-buffer" 286 + version = "0.11.0" 287 + source = "registry+https://github.com/rust-lang/crates.io-index" 288 + checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" 289 + dependencies = [ 290 + "hybrid-array", 291 + ] 292 + 293 + [[package]] 294 + name = "brotli" 295 + version = "8.0.2" 296 + source = "registry+https://github.com/rust-lang/crates.io-index" 297 + checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" 298 + dependencies = [ 299 + "alloc-no-stdlib", 300 + "alloc-stdlib", 301 + "brotli-decompressor", 302 + ] 303 + 304 + [[package]] 305 + name = "brotli-decompressor" 306 + version = "5.0.0" 307 + source = "registry+https://github.com/rust-lang/crates.io-index" 308 + checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" 309 + dependencies = [ 310 + "alloc-no-stdlib", 311 + "alloc-stdlib", 312 + ] 313 + 314 + [[package]] 315 + name = "bumpalo" 316 + version = "3.20.2" 317 + source = "registry+https://github.com/rust-lang/crates.io-index" 318 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 319 + 320 + [[package]] 321 + name = "bytes" 322 + version = "1.11.1" 323 + source = "registry+https://github.com/rust-lang/crates.io-index" 324 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 325 + 326 + [[package]] 327 + name = "cc" 328 + version = "1.2.56" 329 + source = "registry+https://github.com/rust-lang/crates.io-index" 330 + checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" 331 + dependencies = [ 332 + "find-msvc-tools", 333 + "jobserver", 334 + "libc", 335 + "shlex", 336 + ] 337 + 338 + [[package]] 339 + name = "cesu8" 340 + version = "1.1.0" 341 + source = "registry+https://github.com/rust-lang/crates.io-index" 342 + checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 343 + 344 + [[package]] 345 + name = "cfg-if" 346 + version = "1.0.4" 347 + source = "registry+https://github.com/rust-lang/crates.io-index" 348 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 349 + 350 + [[package]] 351 + name = "chrono" 352 + version = "0.4.43" 353 + source = "registry+https://github.com/rust-lang/crates.io-index" 354 + checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" 355 + dependencies = [ 356 + "iana-time-zone", 357 + "num-traits", 358 + "serde", 359 + "windows-link", 360 + ] 361 + 362 + [[package]] 363 + name = "clap" 364 + version = "4.5.60" 365 + source = "registry+https://github.com/rust-lang/crates.io-index" 366 + checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" 367 + dependencies = [ 368 + "clap_builder", 369 + "clap_derive", 370 + ] 371 + 372 + [[package]] 373 + name = "clap_builder" 374 + version = "4.5.60" 375 + source = "registry+https://github.com/rust-lang/crates.io-index" 376 + checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" 377 + dependencies = [ 378 + "anstream", 379 + "anstyle", 380 + "clap_lex", 381 + "strsim", 382 + ] 383 + 384 + [[package]] 385 + name = "clap_derive" 386 + version = "4.5.55" 387 + source = "registry+https://github.com/rust-lang/crates.io-index" 388 + checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" 389 + dependencies = [ 390 + "heck", 391 + "proc-macro2", 392 + "quote", 393 + "syn", 394 + ] 395 + 396 + [[package]] 397 + name = "clap_lex" 398 + version = "1.0.0" 399 + source = "registry+https://github.com/rust-lang/crates.io-index" 400 + checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" 401 + 402 + [[package]] 403 + name = "colorchoice" 404 + version = "1.0.4" 405 + source = "registry+https://github.com/rust-lang/crates.io-index" 406 + checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 407 + 408 + [[package]] 409 + name = "compression-codecs" 410 + version = "0.4.37" 411 + source = "registry+https://github.com/rust-lang/crates.io-index" 412 + checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" 413 + dependencies = [ 414 + "brotli", 415 + "compression-core", 416 + "flate2", 417 + "memchr", 418 + "zstd", 419 + "zstd-safe", 420 + ] 421 + 422 + [[package]] 423 + name = "compression-core" 424 + version = "0.4.31" 425 + source = "registry+https://github.com/rust-lang/crates.io-index" 426 + checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" 427 + 428 + [[package]] 429 + name = "const-oid" 430 + version = "0.10.2" 431 + source = "registry+https://github.com/rust-lang/crates.io-index" 432 + checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" 433 + 434 + [[package]] 435 + name = "core-foundation-sys" 436 + version = "0.8.7" 437 + source = "registry+https://github.com/rust-lang/crates.io-index" 438 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 439 + 440 + [[package]] 441 + name = "cpufeatures" 442 + version = "0.2.17" 443 + source = "registry+https://github.com/rust-lang/crates.io-index" 444 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 445 + dependencies = [ 446 + "libc", 447 + ] 448 + 449 + [[package]] 450 + name = "crab_nbt" 451 + version = "0.2.11" 452 + source = "registry+https://github.com/rust-lang/crates.io-index" 453 + checksum = "66a3ceff0aac8c616138fcc265489fec30427fb9d84922095bc9e555966c308c" 454 + dependencies = [ 455 + "bytes", 456 + "cesu8", 457 + "derive_more", 458 + "serde", 459 + "thiserror", 460 + ] 461 + 462 + [[package]] 463 + name = "crc32fast" 464 + version = "1.5.0" 465 + source = "registry+https://github.com/rust-lang/crates.io-index" 466 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 467 + dependencies = [ 468 + "cfg-if", 469 + ] 470 + 471 + [[package]] 472 + name = "crypto-common" 473 + version = "0.2.0" 474 + source = "registry+https://github.com/rust-lang/crates.io-index" 475 + checksum = "211f05e03c7d03754740fd9e585de910a095d6b99f8bcfffdef8319fa02a8331" 476 + dependencies = [ 477 + "hybrid-array", 478 + ] 479 + 480 + [[package]] 481 + name = "darling" 482 + version = "0.21.3" 483 + source = "registry+https://github.com/rust-lang/crates.io-index" 484 + checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" 485 + dependencies = [ 486 + "darling_core", 487 + "darling_macro", 488 + ] 489 + 490 + [[package]] 491 + name = "darling_core" 492 + version = "0.21.3" 493 + source = "registry+https://github.com/rust-lang/crates.io-index" 494 + checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" 495 + dependencies = [ 496 + "fnv", 497 + "ident_case", 498 + "proc-macro2", 499 + "quote", 500 + "strsim", 501 + "syn", 502 + ] 503 + 504 + [[package]] 505 + name = "darling_macro" 506 + version = "0.21.3" 507 + source = "registry+https://github.com/rust-lang/crates.io-index" 508 + checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" 509 + dependencies = [ 510 + "darling_core", 511 + "quote", 512 + "syn", 513 + ] 514 + 515 + [[package]] 516 + name = "deranged" 517 + version = "0.5.7" 518 + source = "registry+https://github.com/rust-lang/crates.io-index" 519 + checksum = "2163a0e204a148662b6b6816d4b5d5668a5f2f8df498ccbd5cd0e864e78fecba" 520 + dependencies = [ 521 + "powerfmt", 522 + "serde_core", 523 + ] 524 + 525 + [[package]] 526 + name = "derive_more" 527 + version = "2.1.1" 528 + source = "registry+https://github.com/rust-lang/crates.io-index" 529 + checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" 530 + dependencies = [ 531 + "derive_more-impl", 532 + ] 533 + 534 + [[package]] 535 + name = "derive_more-impl" 536 + version = "2.1.1" 537 + source = "registry+https://github.com/rust-lang/crates.io-index" 538 + checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" 539 + dependencies = [ 540 + "proc-macro2", 541 + "quote", 542 + "rustc_version", 543 + "syn", 544 + ] 545 + 546 + [[package]] 547 + name = "digest" 548 + version = "0.11.0" 549 + source = "registry+https://github.com/rust-lang/crates.io-index" 550 + checksum = "f8bf3682cdec91817be507e4aa104314898b95b84d74f3d43882210101a545b6" 551 + dependencies = [ 552 + "block-buffer", 553 + "const-oid", 554 + "crypto-common", 555 + ] 556 + 557 + [[package]] 558 + name = "dyn-clone" 559 + version = "1.0.20" 560 + source = "registry+https://github.com/rust-lang/crates.io-index" 561 + checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 562 + 563 + [[package]] 564 + name = "equivalent" 565 + version = "1.0.2" 566 + source = "registry+https://github.com/rust-lang/crates.io-index" 567 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 568 + 569 + [[package]] 570 + name = "errno" 571 + version = "0.3.14" 572 + source = "registry+https://github.com/rust-lang/crates.io-index" 573 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 574 + dependencies = [ 575 + "libc", 576 + "windows-sys 0.61.2", 577 + ] 578 + 579 + [[package]] 580 + name = "find-msvc-tools" 581 + version = "0.1.9" 582 + source = "registry+https://github.com/rust-lang/crates.io-index" 583 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 584 + 585 + [[package]] 586 + name = "flate2" 587 + version = "1.1.9" 588 + source = "registry+https://github.com/rust-lang/crates.io-index" 589 + checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" 590 + dependencies = [ 591 + "crc32fast", 592 + "miniz_oxide", 593 + ] 594 + 595 + [[package]] 596 + name = "fnv" 597 + version = "1.0.7" 598 + source = "registry+https://github.com/rust-lang/crates.io-index" 599 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 600 + 601 + [[package]] 602 + name = "foldhash" 603 + version = "0.1.5" 604 + source = "registry+https://github.com/rust-lang/crates.io-index" 605 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 606 + 607 + [[package]] 608 + name = "form_urlencoded" 609 + version = "1.2.2" 610 + source = "registry+https://github.com/rust-lang/crates.io-index" 611 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 612 + dependencies = [ 613 + "percent-encoding", 614 + ] 615 + 616 + [[package]] 617 + name = "futures-channel" 618 + version = "0.3.32" 619 + source = "registry+https://github.com/rust-lang/crates.io-index" 620 + checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 621 + dependencies = [ 622 + "futures-core", 623 + ] 624 + 625 + [[package]] 626 + name = "futures-core" 627 + version = "0.3.32" 628 + source = "registry+https://github.com/rust-lang/crates.io-index" 629 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 630 + 631 + [[package]] 632 + name = "futures-sink" 633 + version = "0.3.32" 634 + source = "registry+https://github.com/rust-lang/crates.io-index" 635 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" 636 + 637 + [[package]] 638 + name = "futures-task" 639 + version = "0.3.32" 640 + source = "registry+https://github.com/rust-lang/crates.io-index" 641 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 642 + 643 + [[package]] 644 + name = "futures-util" 645 + version = "0.3.32" 646 + source = "registry+https://github.com/rust-lang/crates.io-index" 647 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 648 + dependencies = [ 649 + "futures-core", 650 + "futures-task", 651 + "pin-project-lite", 652 + "slab", 653 + ] 654 + 655 + [[package]] 656 + name = "getrandom" 657 + version = "0.3.4" 658 + source = "registry+https://github.com/rust-lang/crates.io-index" 659 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 660 + dependencies = [ 661 + "cfg-if", 662 + "libc", 663 + "r-efi", 664 + "wasip2", 665 + ] 666 + 667 + [[package]] 668 + name = "hashbrown" 669 + version = "0.12.3" 670 + source = "registry+https://github.com/rust-lang/crates.io-index" 671 + checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 672 + 673 + [[package]] 674 + name = "hashbrown" 675 + version = "0.15.5" 676 + source = "registry+https://github.com/rust-lang/crates.io-index" 677 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 678 + dependencies = [ 679 + "allocator-api2", 680 + "equivalent", 681 + "foldhash", 682 + ] 683 + 684 + [[package]] 685 + name = "hashbrown" 686 + version = "0.16.1" 687 + source = "registry+https://github.com/rust-lang/crates.io-index" 688 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 689 + 690 + [[package]] 691 + name = "heck" 692 + version = "0.5.0" 693 + source = "registry+https://github.com/rust-lang/crates.io-index" 694 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 695 + 696 + [[package]] 697 + name = "hex" 698 + version = "0.4.3" 699 + source = "registry+https://github.com/rust-lang/crates.io-index" 700 + checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 701 + 702 + [[package]] 703 + name = "html-escape" 704 + version = "0.2.13" 705 + source = "registry+https://github.com/rust-lang/crates.io-index" 706 + checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" 707 + dependencies = [ 708 + "utf8-width", 709 + ] 710 + 711 + [[package]] 712 + name = "http" 713 + version = "1.4.0" 714 + source = "registry+https://github.com/rust-lang/crates.io-index" 715 + checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" 716 + dependencies = [ 717 + "bytes", 718 + "itoa", 719 + ] 720 + 721 + [[package]] 722 + name = "http-body" 723 + version = "1.0.1" 724 + source = "registry+https://github.com/rust-lang/crates.io-index" 725 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 726 + dependencies = [ 727 + "bytes", 728 + "http", 729 + ] 730 + 731 + [[package]] 732 + name = "http-body-util" 733 + version = "0.1.3" 734 + source = "registry+https://github.com/rust-lang/crates.io-index" 735 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 736 + dependencies = [ 737 + "bytes", 738 + "futures-core", 739 + "http", 740 + "http-body", 741 + "pin-project-lite", 742 + ] 743 + 744 + [[package]] 745 + name = "httparse" 746 + version = "1.10.1" 747 + source = "registry+https://github.com/rust-lang/crates.io-index" 748 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 749 + 750 + [[package]] 751 + name = "httpdate" 752 + version = "1.0.3" 753 + source = "registry+https://github.com/rust-lang/crates.io-index" 754 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 755 + 756 + [[package]] 757 + name = "hybrid-array" 758 + version = "0.4.7" 759 + source = "registry+https://github.com/rust-lang/crates.io-index" 760 + checksum = "e1b229d73f5803b562cc26e4da0396c8610a4ee209f4fac8fa4f8d709166dc45" 761 + dependencies = [ 762 + "typenum", 763 + ] 764 + 765 + [[package]] 766 + name = "hyper" 767 + version = "1.8.1" 768 + source = "registry+https://github.com/rust-lang/crates.io-index" 769 + checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" 770 + dependencies = [ 771 + "atomic-waker", 772 + "bytes", 773 + "futures-channel", 774 + "futures-core", 775 + "http", 776 + "http-body", 777 + "httparse", 778 + "httpdate", 779 + "itoa", 780 + "pin-project-lite", 781 + "pin-utils", 782 + "smallvec", 783 + "tokio", 784 + ] 785 + 786 + [[package]] 787 + name = "hyper-util" 788 + version = "0.1.20" 789 + source = "registry+https://github.com/rust-lang/crates.io-index" 790 + checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" 791 + dependencies = [ 792 + "bytes", 793 + "http", 794 + "http-body", 795 + "hyper", 796 + "pin-project-lite", 797 + "tokio", 798 + "tower-service", 799 + ] 800 + 801 + [[package]] 802 + name = "iana-time-zone" 803 + version = "0.1.65" 804 + source = "registry+https://github.com/rust-lang/crates.io-index" 805 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" 806 + dependencies = [ 807 + "android_system_properties", 808 + "core-foundation-sys", 809 + "iana-time-zone-haiku", 810 + "js-sys", 811 + "log", 812 + "wasm-bindgen", 813 + "windows-core", 814 + ] 815 + 816 + [[package]] 817 + name = "iana-time-zone-haiku" 818 + version = "0.1.2" 819 + source = "registry+https://github.com/rust-lang/crates.io-index" 820 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 821 + dependencies = [ 822 + "cc", 823 + ] 824 + 825 + [[package]] 826 + name = "ident_case" 827 + version = "1.0.1" 828 + source = "registry+https://github.com/rust-lang/crates.io-index" 829 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 830 + 831 + [[package]] 832 + name = "indexmap" 833 + version = "1.9.3" 834 + source = "registry+https://github.com/rust-lang/crates.io-index" 835 + checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" 836 + dependencies = [ 837 + "autocfg", 838 + "hashbrown 0.12.3", 839 + "serde", 840 + ] 841 + 842 + [[package]] 843 + name = "indexmap" 844 + version = "2.13.0" 845 + source = "registry+https://github.com/rust-lang/crates.io-index" 846 + checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" 847 + dependencies = [ 848 + "equivalent", 849 + "hashbrown 0.16.1", 850 + "serde", 851 + "serde_core", 852 + ] 853 + 854 + [[package]] 855 + name = "is_terminal_polyfill" 856 + version = "1.70.2" 857 + source = "registry+https://github.com/rust-lang/crates.io-index" 858 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 859 + 860 + [[package]] 861 + name = "itoa" 862 + version = "1.0.17" 863 + source = "registry+https://github.com/rust-lang/crates.io-index" 864 + checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 865 + 866 + [[package]] 867 + name = "jobserver" 868 + version = "0.1.34" 869 + source = "registry+https://github.com/rust-lang/crates.io-index" 870 + checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 871 + dependencies = [ 872 + "getrandom", 873 + "libc", 874 + ] 875 + 876 + [[package]] 877 + name = "js-sys" 878 + version = "0.3.87" 879 + source = "registry+https://github.com/rust-lang/crates.io-index" 880 + checksum = "93f0862381daaec758576dcc22eb7bbf4d7efd67328553f3b45a412a51a3fb21" 881 + dependencies = [ 882 + "once_cell", 883 + "wasm-bindgen", 884 + ] 885 + 886 + [[package]] 887 + name = "lazy_static" 888 + version = "1.5.0" 889 + source = "registry+https://github.com/rust-lang/crates.io-index" 890 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 891 + 892 + [[package]] 893 + name = "libc" 894 + version = "0.2.182" 895 + source = "registry+https://github.com/rust-lang/crates.io-index" 896 + checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" 897 + 898 + [[package]] 899 + name = "lock_api" 900 + version = "0.4.14" 901 + source = "registry+https://github.com/rust-lang/crates.io-index" 902 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 903 + dependencies = [ 904 + "scopeguard", 905 + ] 906 + 907 + [[package]] 908 + name = "log" 909 + version = "0.4.29" 910 + source = "registry+https://github.com/rust-lang/crates.io-index" 911 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 912 + 913 + [[package]] 914 + name = "lru" 915 + version = "0.12.5" 916 + source = "registry+https://github.com/rust-lang/crates.io-index" 917 + checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 918 + dependencies = [ 919 + "hashbrown 0.15.5", 920 + ] 921 + 922 + [[package]] 923 + name = "matchit" 924 + version = "0.8.4" 925 + source = "registry+https://github.com/rust-lang/crates.io-index" 926 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 927 + 928 + [[package]] 929 + name = "memchr" 930 + version = "2.8.0" 931 + source = "registry+https://github.com/rust-lang/crates.io-index" 932 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 933 + 934 + [[package]] 935 + name = "mime" 936 + version = "0.3.17" 937 + source = "registry+https://github.com/rust-lang/crates.io-index" 938 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 939 + 940 + [[package]] 941 + name = "miniz_oxide" 942 + version = "0.8.9" 943 + source = "registry+https://github.com/rust-lang/crates.io-index" 944 + checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 945 + dependencies = [ 946 + "adler2", 947 + "simd-adler32", 948 + ] 949 + 950 + [[package]] 951 + name = "mio" 952 + version = "1.1.1" 953 + source = "registry+https://github.com/rust-lang/crates.io-index" 954 + checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" 955 + dependencies = [ 956 + "libc", 957 + "wasi", 958 + "windows-sys 0.61.2", 959 + ] 960 + 961 + [[package]] 962 + name = "nara" 963 + version = "0.1.0" 964 + dependencies = [ 965 + "ahash", 966 + "anyhow", 967 + "askama", 968 + "askama_web", 969 + "axum", 970 + "clap", 971 + "crab_nbt", 972 + "hex", 973 + "html-escape", 974 + "lru", 975 + "serde", 976 + "serde_json", 977 + "serde_with", 978 + "sha1", 979 + "smol_str", 980 + "strsim", 981 + "thiserror", 982 + "tokio", 983 + "tower-http", 984 + "tracing", 985 + "tracing-subscriber", 986 + ] 987 + 988 + [[package]] 989 + name = "nu-ansi-term" 990 + version = "0.50.3" 991 + source = "registry+https://github.com/rust-lang/crates.io-index" 992 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 993 + dependencies = [ 994 + "windows-sys 0.61.2", 995 + ] 996 + 997 + [[package]] 998 + name = "num-conv" 999 + version = "0.2.0" 1000 + source = "registry+https://github.com/rust-lang/crates.io-index" 1001 + checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" 1002 + 1003 + [[package]] 1004 + name = "num-traits" 1005 + version = "0.2.19" 1006 + source = "registry+https://github.com/rust-lang/crates.io-index" 1007 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1008 + dependencies = [ 1009 + "autocfg", 1010 + ] 1011 + 1012 + [[package]] 1013 + name = "once_cell" 1014 + version = "1.21.3" 1015 + source = "registry+https://github.com/rust-lang/crates.io-index" 1016 + checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 1017 + 1018 + [[package]] 1019 + name = "once_cell_polyfill" 1020 + version = "1.70.2" 1021 + source = "registry+https://github.com/rust-lang/crates.io-index" 1022 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 1023 + 1024 + [[package]] 1025 + name = "parking_lot" 1026 + version = "0.12.5" 1027 + source = "registry+https://github.com/rust-lang/crates.io-index" 1028 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 1029 + dependencies = [ 1030 + "lock_api", 1031 + "parking_lot_core", 1032 + ] 1033 + 1034 + [[package]] 1035 + name = "parking_lot_core" 1036 + version = "0.9.12" 1037 + source = "registry+https://github.com/rust-lang/crates.io-index" 1038 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 1039 + dependencies = [ 1040 + "cfg-if", 1041 + "libc", 1042 + "redox_syscall", 1043 + "smallvec", 1044 + "windows-link", 1045 + ] 1046 + 1047 + [[package]] 1048 + name = "percent-encoding" 1049 + version = "2.3.2" 1050 + source = "registry+https://github.com/rust-lang/crates.io-index" 1051 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 1052 + 1053 + [[package]] 1054 + name = "pin-project-lite" 1055 + version = "0.2.16" 1056 + source = "registry+https://github.com/rust-lang/crates.io-index" 1057 + checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 1058 + 1059 + [[package]] 1060 + name = "pin-utils" 1061 + version = "0.1.0" 1062 + source = "registry+https://github.com/rust-lang/crates.io-index" 1063 + checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1064 + 1065 + [[package]] 1066 + name = "pkg-config" 1067 + version = "0.3.32" 1068 + source = "registry+https://github.com/rust-lang/crates.io-index" 1069 + checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 1070 + 1071 + [[package]] 1072 + name = "powerfmt" 1073 + version = "0.2.0" 1074 + source = "registry+https://github.com/rust-lang/crates.io-index" 1075 + checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 1076 + 1077 + [[package]] 1078 + name = "proc-macro2" 1079 + version = "1.0.106" 1080 + source = "registry+https://github.com/rust-lang/crates.io-index" 1081 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 1082 + dependencies = [ 1083 + "unicode-ident", 1084 + ] 1085 + 1086 + [[package]] 1087 + name = "quote" 1088 + version = "1.0.44" 1089 + source = "registry+https://github.com/rust-lang/crates.io-index" 1090 + checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" 1091 + dependencies = [ 1092 + "proc-macro2", 1093 + ] 1094 + 1095 + [[package]] 1096 + name = "r-efi" 1097 + version = "5.3.0" 1098 + source = "registry+https://github.com/rust-lang/crates.io-index" 1099 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 1100 + 1101 + [[package]] 1102 + name = "redox_syscall" 1103 + version = "0.5.18" 1104 + source = "registry+https://github.com/rust-lang/crates.io-index" 1105 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 1106 + dependencies = [ 1107 + "bitflags", 1108 + ] 1109 + 1110 + [[package]] 1111 + name = "ref-cast" 1112 + version = "1.0.25" 1113 + source = "registry+https://github.com/rust-lang/crates.io-index" 1114 + checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" 1115 + dependencies = [ 1116 + "ref-cast-impl", 1117 + ] 1118 + 1119 + [[package]] 1120 + name = "ref-cast-impl" 1121 + version = "1.0.25" 1122 + source = "registry+https://github.com/rust-lang/crates.io-index" 1123 + checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" 1124 + dependencies = [ 1125 + "proc-macro2", 1126 + "quote", 1127 + "syn", 1128 + ] 1129 + 1130 + [[package]] 1131 + name = "rustc-hash" 1132 + version = "2.1.1" 1133 + source = "registry+https://github.com/rust-lang/crates.io-index" 1134 + checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 1135 + 1136 + [[package]] 1137 + name = "rustc_version" 1138 + version = "0.4.1" 1139 + source = "registry+https://github.com/rust-lang/crates.io-index" 1140 + checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 1141 + dependencies = [ 1142 + "semver", 1143 + ] 1144 + 1145 + [[package]] 1146 + name = "rustversion" 1147 + version = "1.0.22" 1148 + source = "registry+https://github.com/rust-lang/crates.io-index" 1149 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 1150 + 1151 + [[package]] 1152 + name = "ryu" 1153 + version = "1.0.23" 1154 + source = "registry+https://github.com/rust-lang/crates.io-index" 1155 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 1156 + 1157 + [[package]] 1158 + name = "schemars" 1159 + version = "0.9.0" 1160 + source = "registry+https://github.com/rust-lang/crates.io-index" 1161 + checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" 1162 + dependencies = [ 1163 + "dyn-clone", 1164 + "ref-cast", 1165 + "serde", 1166 + "serde_json", 1167 + ] 1168 + 1169 + [[package]] 1170 + name = "schemars" 1171 + version = "1.2.1" 1172 + source = "registry+https://github.com/rust-lang/crates.io-index" 1173 + checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" 1174 + dependencies = [ 1175 + "dyn-clone", 1176 + "ref-cast", 1177 + "serde", 1178 + "serde_json", 1179 + ] 1180 + 1181 + [[package]] 1182 + name = "scopeguard" 1183 + version = "1.2.0" 1184 + source = "registry+https://github.com/rust-lang/crates.io-index" 1185 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1186 + 1187 + [[package]] 1188 + name = "semver" 1189 + version = "1.0.27" 1190 + source = "registry+https://github.com/rust-lang/crates.io-index" 1191 + checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 1192 + 1193 + [[package]] 1194 + name = "serde" 1195 + version = "1.0.228" 1196 + source = "registry+https://github.com/rust-lang/crates.io-index" 1197 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 1198 + dependencies = [ 1199 + "serde_core", 1200 + "serde_derive", 1201 + ] 1202 + 1203 + [[package]] 1204 + name = "serde_core" 1205 + version = "1.0.228" 1206 + source = "registry+https://github.com/rust-lang/crates.io-index" 1207 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 1208 + dependencies = [ 1209 + "serde_derive", 1210 + ] 1211 + 1212 + [[package]] 1213 + name = "serde_derive" 1214 + version = "1.0.228" 1215 + source = "registry+https://github.com/rust-lang/crates.io-index" 1216 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 1217 + dependencies = [ 1218 + "proc-macro2", 1219 + "quote", 1220 + "syn", 1221 + ] 1222 + 1223 + [[package]] 1224 + name = "serde_json" 1225 + version = "1.0.149" 1226 + source = "registry+https://github.com/rust-lang/crates.io-index" 1227 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 1228 + dependencies = [ 1229 + "itoa", 1230 + "memchr", 1231 + "serde", 1232 + "serde_core", 1233 + "zmij", 1234 + ] 1235 + 1236 + [[package]] 1237 + name = "serde_path_to_error" 1238 + version = "0.1.20" 1239 + source = "registry+https://github.com/rust-lang/crates.io-index" 1240 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 1241 + dependencies = [ 1242 + "itoa", 1243 + "serde", 1244 + "serde_core", 1245 + ] 1246 + 1247 + [[package]] 1248 + name = "serde_urlencoded" 1249 + version = "0.7.1" 1250 + source = "registry+https://github.com/rust-lang/crates.io-index" 1251 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1252 + dependencies = [ 1253 + "form_urlencoded", 1254 + "itoa", 1255 + "ryu", 1256 + "serde", 1257 + ] 1258 + 1259 + [[package]] 1260 + name = "serde_with" 1261 + version = "3.16.1" 1262 + source = "registry+https://github.com/rust-lang/crates.io-index" 1263 + checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" 1264 + dependencies = [ 1265 + "base64", 1266 + "chrono", 1267 + "hex", 1268 + "indexmap 1.9.3", 1269 + "indexmap 2.13.0", 1270 + "schemars 0.9.0", 1271 + "schemars 1.2.1", 1272 + "serde_core", 1273 + "serde_json", 1274 + "serde_with_macros", 1275 + "time", 1276 + ] 1277 + 1278 + [[package]] 1279 + name = "serde_with_macros" 1280 + version = "3.16.1" 1281 + source = "registry+https://github.com/rust-lang/crates.io-index" 1282 + checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" 1283 + dependencies = [ 1284 + "darling", 1285 + "proc-macro2", 1286 + "quote", 1287 + "syn", 1288 + ] 1289 + 1290 + [[package]] 1291 + name = "sha1" 1292 + version = "0.11.0-rc.5" 1293 + source = "registry+https://github.com/rust-lang/crates.io-index" 1294 + checksum = "3b167252f3c126be0d8926639c4c4706950f01445900c4b3db0fd7e89fcb750a" 1295 + dependencies = [ 1296 + "cfg-if", 1297 + "cpufeatures", 1298 + "digest", 1299 + ] 1300 + 1301 + [[package]] 1302 + name = "sharded-slab" 1303 + version = "0.1.7" 1304 + source = "registry+https://github.com/rust-lang/crates.io-index" 1305 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 1306 + dependencies = [ 1307 + "lazy_static", 1308 + ] 1309 + 1310 + [[package]] 1311 + name = "shlex" 1312 + version = "1.3.0" 1313 + source = "registry+https://github.com/rust-lang/crates.io-index" 1314 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1315 + 1316 + [[package]] 1317 + name = "signal-hook-registry" 1318 + version = "1.4.8" 1319 + source = "registry+https://github.com/rust-lang/crates.io-index" 1320 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 1321 + dependencies = [ 1322 + "errno", 1323 + "libc", 1324 + ] 1325 + 1326 + [[package]] 1327 + name = "simd-adler32" 1328 + version = "0.3.8" 1329 + source = "registry+https://github.com/rust-lang/crates.io-index" 1330 + checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 1331 + 1332 + [[package]] 1333 + name = "slab" 1334 + version = "0.4.12" 1335 + source = "registry+https://github.com/rust-lang/crates.io-index" 1336 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 1337 + 1338 + [[package]] 1339 + name = "smallvec" 1340 + version = "1.15.1" 1341 + source = "registry+https://github.com/rust-lang/crates.io-index" 1342 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 1343 + 1344 + [[package]] 1345 + name = "smol_str" 1346 + version = "0.2.2" 1347 + source = "registry+https://github.com/rust-lang/crates.io-index" 1348 + checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" 1349 + dependencies = [ 1350 + "serde", 1351 + ] 1352 + 1353 + [[package]] 1354 + name = "socket2" 1355 + version = "0.6.2" 1356 + source = "registry+https://github.com/rust-lang/crates.io-index" 1357 + checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" 1358 + dependencies = [ 1359 + "libc", 1360 + "windows-sys 0.60.2", 1361 + ] 1362 + 1363 + [[package]] 1364 + name = "strsim" 1365 + version = "0.11.1" 1366 + source = "registry+https://github.com/rust-lang/crates.io-index" 1367 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1368 + 1369 + [[package]] 1370 + name = "syn" 1371 + version = "2.0.117" 1372 + source = "registry+https://github.com/rust-lang/crates.io-index" 1373 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 1374 + dependencies = [ 1375 + "proc-macro2", 1376 + "quote", 1377 + "unicode-ident", 1378 + ] 1379 + 1380 + [[package]] 1381 + name = "sync_wrapper" 1382 + version = "1.0.2" 1383 + source = "registry+https://github.com/rust-lang/crates.io-index" 1384 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 1385 + 1386 + [[package]] 1387 + name = "thiserror" 1388 + version = "2.0.18" 1389 + source = "registry+https://github.com/rust-lang/crates.io-index" 1390 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 1391 + dependencies = [ 1392 + "thiserror-impl", 1393 + ] 1394 + 1395 + [[package]] 1396 + name = "thiserror-impl" 1397 + version = "2.0.18" 1398 + source = "registry+https://github.com/rust-lang/crates.io-index" 1399 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 1400 + dependencies = [ 1401 + "proc-macro2", 1402 + "quote", 1403 + "syn", 1404 + ] 1405 + 1406 + [[package]] 1407 + name = "thread_local" 1408 + version = "1.1.9" 1409 + source = "registry+https://github.com/rust-lang/crates.io-index" 1410 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 1411 + dependencies = [ 1412 + "cfg-if", 1413 + ] 1414 + 1415 + [[package]] 1416 + name = "time" 1417 + version = "0.3.47" 1418 + source = "registry+https://github.com/rust-lang/crates.io-index" 1419 + checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" 1420 + dependencies = [ 1421 + "deranged", 1422 + "itoa", 1423 + "num-conv", 1424 + "powerfmt", 1425 + "serde_core", 1426 + "time-core", 1427 + "time-macros", 1428 + ] 1429 + 1430 + [[package]] 1431 + name = "time-core" 1432 + version = "0.1.8" 1433 + source = "registry+https://github.com/rust-lang/crates.io-index" 1434 + checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" 1435 + 1436 + [[package]] 1437 + name = "time-macros" 1438 + version = "0.2.27" 1439 + source = "registry+https://github.com/rust-lang/crates.io-index" 1440 + checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" 1441 + dependencies = [ 1442 + "num-conv", 1443 + "time-core", 1444 + ] 1445 + 1446 + [[package]] 1447 + name = "tokio" 1448 + version = "1.49.0" 1449 + source = "registry+https://github.com/rust-lang/crates.io-index" 1450 + checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" 1451 + dependencies = [ 1452 + "bytes", 1453 + "libc", 1454 + "mio", 1455 + "parking_lot", 1456 + "pin-project-lite", 1457 + "signal-hook-registry", 1458 + "socket2", 1459 + "tokio-macros", 1460 + "windows-sys 0.61.2", 1461 + ] 1462 + 1463 + [[package]] 1464 + name = "tokio-macros" 1465 + version = "2.6.0" 1466 + source = "registry+https://github.com/rust-lang/crates.io-index" 1467 + checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" 1468 + dependencies = [ 1469 + "proc-macro2", 1470 + "quote", 1471 + "syn", 1472 + ] 1473 + 1474 + [[package]] 1475 + name = "tokio-util" 1476 + version = "0.7.18" 1477 + source = "registry+https://github.com/rust-lang/crates.io-index" 1478 + checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" 1479 + dependencies = [ 1480 + "bytes", 1481 + "futures-core", 1482 + "futures-sink", 1483 + "pin-project-lite", 1484 + "tokio", 1485 + ] 1486 + 1487 + [[package]] 1488 + name = "tower" 1489 + version = "0.5.3" 1490 + source = "registry+https://github.com/rust-lang/crates.io-index" 1491 + checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" 1492 + dependencies = [ 1493 + "futures-core", 1494 + "futures-util", 1495 + "pin-project-lite", 1496 + "sync_wrapper", 1497 + "tokio", 1498 + "tower-layer", 1499 + "tower-service", 1500 + "tracing", 1501 + ] 1502 + 1503 + [[package]] 1504 + name = "tower-http" 1505 + version = "0.6.8" 1506 + source = "registry+https://github.com/rust-lang/crates.io-index" 1507 + checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" 1508 + dependencies = [ 1509 + "async-compression", 1510 + "bitflags", 1511 + "bytes", 1512 + "futures-core", 1513 + "http", 1514 + "http-body", 1515 + "pin-project-lite", 1516 + "tokio", 1517 + "tokio-util", 1518 + "tower-layer", 1519 + "tower-service", 1520 + ] 1521 + 1522 + [[package]] 1523 + name = "tower-layer" 1524 + version = "0.3.3" 1525 + source = "registry+https://github.com/rust-lang/crates.io-index" 1526 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 1527 + 1528 + [[package]] 1529 + name = "tower-service" 1530 + version = "0.3.3" 1531 + source = "registry+https://github.com/rust-lang/crates.io-index" 1532 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 1533 + 1534 + [[package]] 1535 + name = "tracing" 1536 + version = "0.1.44" 1537 + source = "registry+https://github.com/rust-lang/crates.io-index" 1538 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 1539 + dependencies = [ 1540 + "log", 1541 + "pin-project-lite", 1542 + "tracing-attributes", 1543 + "tracing-core", 1544 + ] 1545 + 1546 + [[package]] 1547 + name = "tracing-attributes" 1548 + version = "0.1.31" 1549 + source = "registry+https://github.com/rust-lang/crates.io-index" 1550 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" 1551 + dependencies = [ 1552 + "proc-macro2", 1553 + "quote", 1554 + "syn", 1555 + ] 1556 + 1557 + [[package]] 1558 + name = "tracing-core" 1559 + version = "0.1.36" 1560 + source = "registry+https://github.com/rust-lang/crates.io-index" 1561 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 1562 + dependencies = [ 1563 + "once_cell", 1564 + "valuable", 1565 + ] 1566 + 1567 + [[package]] 1568 + name = "tracing-log" 1569 + version = "0.2.0" 1570 + source = "registry+https://github.com/rust-lang/crates.io-index" 1571 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 1572 + dependencies = [ 1573 + "log", 1574 + "once_cell", 1575 + "tracing-core", 1576 + ] 1577 + 1578 + [[package]] 1579 + name = "tracing-subscriber" 1580 + version = "0.3.22" 1581 + source = "registry+https://github.com/rust-lang/crates.io-index" 1582 + checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" 1583 + dependencies = [ 1584 + "nu-ansi-term", 1585 + "sharded-slab", 1586 + "smallvec", 1587 + "thread_local", 1588 + "tracing-core", 1589 + "tracing-log", 1590 + ] 1591 + 1592 + [[package]] 1593 + name = "typenum" 1594 + version = "1.19.0" 1595 + source = "registry+https://github.com/rust-lang/crates.io-index" 1596 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 1597 + 1598 + [[package]] 1599 + name = "unicode-ident" 1600 + version = "1.0.24" 1601 + source = "registry+https://github.com/rust-lang/crates.io-index" 1602 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 1603 + 1604 + [[package]] 1605 + name = "utf8-width" 1606 + version = "0.1.8" 1607 + source = "registry+https://github.com/rust-lang/crates.io-index" 1608 + checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" 1609 + 1610 + [[package]] 1611 + name = "utf8parse" 1612 + version = "0.2.2" 1613 + source = "registry+https://github.com/rust-lang/crates.io-index" 1614 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1615 + 1616 + [[package]] 1617 + name = "valuable" 1618 + version = "0.1.1" 1619 + source = "registry+https://github.com/rust-lang/crates.io-index" 1620 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 1621 + 1622 + [[package]] 1623 + name = "version_check" 1624 + version = "0.9.5" 1625 + source = "registry+https://github.com/rust-lang/crates.io-index" 1626 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1627 + 1628 + [[package]] 1629 + name = "wasi" 1630 + version = "0.11.1+wasi-snapshot-preview1" 1631 + source = "registry+https://github.com/rust-lang/crates.io-index" 1632 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 1633 + 1634 + [[package]] 1635 + name = "wasip2" 1636 + version = "1.0.2+wasi-0.2.9" 1637 + source = "registry+https://github.com/rust-lang/crates.io-index" 1638 + checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" 1639 + dependencies = [ 1640 + "wit-bindgen", 1641 + ] 1642 + 1643 + [[package]] 1644 + name = "wasm-bindgen" 1645 + version = "0.2.110" 1646 + source = "registry+https://github.com/rust-lang/crates.io-index" 1647 + checksum = "1de241cdc66a9d91bd84f097039eb140cdc6eec47e0cdbaf9d932a1dd6c35866" 1648 + dependencies = [ 1649 + "cfg-if", 1650 + "once_cell", 1651 + "rustversion", 1652 + "wasm-bindgen-macro", 1653 + "wasm-bindgen-shared", 1654 + ] 1655 + 1656 + [[package]] 1657 + name = "wasm-bindgen-macro" 1658 + version = "0.2.110" 1659 + source = "registry+https://github.com/rust-lang/crates.io-index" 1660 + checksum = "e12fdf6649048f2e3de6d7d5ff3ced779cdedee0e0baffd7dff5cdfa3abc8a52" 1661 + dependencies = [ 1662 + "quote", 1663 + "wasm-bindgen-macro-support", 1664 + ] 1665 + 1666 + [[package]] 1667 + name = "wasm-bindgen-macro-support" 1668 + version = "0.2.110" 1669 + source = "registry+https://github.com/rust-lang/crates.io-index" 1670 + checksum = "0e63d1795c565ac3462334c1e396fd46dbf481c40f51f5072c310717bc4fb309" 1671 + dependencies = [ 1672 + "bumpalo", 1673 + "proc-macro2", 1674 + "quote", 1675 + "syn", 1676 + "wasm-bindgen-shared", 1677 + ] 1678 + 1679 + [[package]] 1680 + name = "wasm-bindgen-shared" 1681 + version = "0.2.110" 1682 + source = "registry+https://github.com/rust-lang/crates.io-index" 1683 + checksum = "e9f9cdac23a5ce71f6bf9f8824898a501e511892791ea2a0c6b8568c68b9cb53" 1684 + dependencies = [ 1685 + "unicode-ident", 1686 + ] 1687 + 1688 + [[package]] 1689 + name = "windows-core" 1690 + version = "0.62.2" 1691 + source = "registry+https://github.com/rust-lang/crates.io-index" 1692 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 1693 + dependencies = [ 1694 + "windows-implement", 1695 + "windows-interface", 1696 + "windows-link", 1697 + "windows-result", 1698 + "windows-strings", 1699 + ] 1700 + 1701 + [[package]] 1702 + name = "windows-implement" 1703 + version = "0.60.2" 1704 + source = "registry+https://github.com/rust-lang/crates.io-index" 1705 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 1706 + dependencies = [ 1707 + "proc-macro2", 1708 + "quote", 1709 + "syn", 1710 + ] 1711 + 1712 + [[package]] 1713 + name = "windows-interface" 1714 + version = "0.59.3" 1715 + source = "registry+https://github.com/rust-lang/crates.io-index" 1716 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 1717 + dependencies = [ 1718 + "proc-macro2", 1719 + "quote", 1720 + "syn", 1721 + ] 1722 + 1723 + [[package]] 1724 + name = "windows-link" 1725 + version = "0.2.1" 1726 + source = "registry+https://github.com/rust-lang/crates.io-index" 1727 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 1728 + 1729 + [[package]] 1730 + name = "windows-result" 1731 + version = "0.4.1" 1732 + source = "registry+https://github.com/rust-lang/crates.io-index" 1733 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 1734 + dependencies = [ 1735 + "windows-link", 1736 + ] 1737 + 1738 + [[package]] 1739 + name = "windows-strings" 1740 + version = "0.5.1" 1741 + source = "registry+https://github.com/rust-lang/crates.io-index" 1742 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 1743 + dependencies = [ 1744 + "windows-link", 1745 + ] 1746 + 1747 + [[package]] 1748 + name = "windows-sys" 1749 + version = "0.60.2" 1750 + source = "registry+https://github.com/rust-lang/crates.io-index" 1751 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 1752 + dependencies = [ 1753 + "windows-targets", 1754 + ] 1755 + 1756 + [[package]] 1757 + name = "windows-sys" 1758 + version = "0.61.2" 1759 + source = "registry+https://github.com/rust-lang/crates.io-index" 1760 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 1761 + dependencies = [ 1762 + "windows-link", 1763 + ] 1764 + 1765 + [[package]] 1766 + name = "windows-targets" 1767 + version = "0.53.5" 1768 + source = "registry+https://github.com/rust-lang/crates.io-index" 1769 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 1770 + dependencies = [ 1771 + "windows-link", 1772 + "windows_aarch64_gnullvm", 1773 + "windows_aarch64_msvc", 1774 + "windows_i686_gnu", 1775 + "windows_i686_gnullvm", 1776 + "windows_i686_msvc", 1777 + "windows_x86_64_gnu", 1778 + "windows_x86_64_gnullvm", 1779 + "windows_x86_64_msvc", 1780 + ] 1781 + 1782 + [[package]] 1783 + name = "windows_aarch64_gnullvm" 1784 + version = "0.53.1" 1785 + source = "registry+https://github.com/rust-lang/crates.io-index" 1786 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 1787 + 1788 + [[package]] 1789 + name = "windows_aarch64_msvc" 1790 + version = "0.53.1" 1791 + source = "registry+https://github.com/rust-lang/crates.io-index" 1792 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 1793 + 1794 + [[package]] 1795 + name = "windows_i686_gnu" 1796 + version = "0.53.1" 1797 + source = "registry+https://github.com/rust-lang/crates.io-index" 1798 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 1799 + 1800 + [[package]] 1801 + name = "windows_i686_gnullvm" 1802 + version = "0.53.1" 1803 + source = "registry+https://github.com/rust-lang/crates.io-index" 1804 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 1805 + 1806 + [[package]] 1807 + name = "windows_i686_msvc" 1808 + version = "0.53.1" 1809 + source = "registry+https://github.com/rust-lang/crates.io-index" 1810 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 1811 + 1812 + [[package]] 1813 + name = "windows_x86_64_gnu" 1814 + version = "0.53.1" 1815 + source = "registry+https://github.com/rust-lang/crates.io-index" 1816 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 1817 + 1818 + [[package]] 1819 + name = "windows_x86_64_gnullvm" 1820 + version = "0.53.1" 1821 + source = "registry+https://github.com/rust-lang/crates.io-index" 1822 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 1823 + 1824 + [[package]] 1825 + name = "windows_x86_64_msvc" 1826 + version = "0.53.1" 1827 + source = "registry+https://github.com/rust-lang/crates.io-index" 1828 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 1829 + 1830 + [[package]] 1831 + name = "winnow" 1832 + version = "0.7.14" 1833 + source = "registry+https://github.com/rust-lang/crates.io-index" 1834 + checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" 1835 + dependencies = [ 1836 + "memchr", 1837 + ] 1838 + 1839 + [[package]] 1840 + name = "wit-bindgen" 1841 + version = "0.51.0" 1842 + source = "registry+https://github.com/rust-lang/crates.io-index" 1843 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 1844 + 1845 + [[package]] 1846 + name = "zerocopy" 1847 + version = "0.8.39" 1848 + source = "registry+https://github.com/rust-lang/crates.io-index" 1849 + checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" 1850 + dependencies = [ 1851 + "zerocopy-derive", 1852 + ] 1853 + 1854 + [[package]] 1855 + name = "zerocopy-derive" 1856 + version = "0.8.39" 1857 + source = "registry+https://github.com/rust-lang/crates.io-index" 1858 + checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" 1859 + dependencies = [ 1860 + "proc-macro2", 1861 + "quote", 1862 + "syn", 1863 + ] 1864 + 1865 + [[package]] 1866 + name = "zmij" 1867 + version = "1.0.21" 1868 + source = "registry+https://github.com/rust-lang/crates.io-index" 1869 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 1870 + 1871 + [[package]] 1872 + name = "zstd" 1873 + version = "0.13.3" 1874 + source = "registry+https://github.com/rust-lang/crates.io-index" 1875 + checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" 1876 + dependencies = [ 1877 + "zstd-safe", 1878 + ] 1879 + 1880 + [[package]] 1881 + name = "zstd-safe" 1882 + version = "7.2.4" 1883 + source = "registry+https://github.com/rust-lang/crates.io-index" 1884 + checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" 1885 + dependencies = [ 1886 + "zstd-sys", 1887 + ] 1888 + 1889 + [[package]] 1890 + name = "zstd-sys" 1891 + version = "2.0.16+zstd.1.5.7" 1892 + source = "registry+https://github.com/rust-lang/crates.io-index" 1893 + checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" 1894 + dependencies = [ 1895 + "cc", 1896 + "pkg-config", 1897 + ]
+27
Cargo.toml
··· 1 + [package] 2 + name = "nara" 3 + version = "0.1.0" 4 + edition = "2024" 5 + 6 + [dependencies] 7 + ahash = "0.8" 8 + anyhow = "1.0" 9 + askama = "0.15" 10 + askama_web = { version = "0.15", features = ["axum-0.8", "tracing-0.1"] } 11 + axum = "0.8" 12 + clap = { version = "4.5", features = ["derive"] } 13 + crab_nbt = { version = "0.2", features = ["serde"] } 14 + hex = "0.4" 15 + html-escape = "0.2" 16 + lru = "0.12" 17 + serde = { version = "1.0", features = ["derive"] } 18 + serde_json = "1.0" 19 + serde_with = "3.16" 20 + sha1 = "0.11.0-rc.5" 21 + smol_str = "0.2" 22 + strsim = "0.11" 23 + thiserror = "2.0" 24 + tokio = { version = "1.0", features = ["full"] } 25 + tower-http = { version = "0.6", features = ["compression-full"] } 26 + tracing = "0.1" 27 + tracing-subscriber = "0.3"
+77
README.md
··· 1 + # nara 2 + 3 + A web-based book archival/viewing tool for Minecraft 1.12.2 data exported via Infinity Item Editor. 4 + 5 + ## Prerequisites 6 + 7 + - [Rust toolchain][rust] (to compile) 8 + - [Minecraft: Java Edition][mc] (1.12.2) 9 + - [Minecraft Forge for 1.12.2][forge] 10 + - [Infinity Item Editor][iie] mod 11 + 12 + ## Usage 13 + 14 + ### 1. Build 15 + 16 + ```sh 17 + cargo build --release 18 + ```` 19 + 20 + The binary will be in `target/release/` (`nara` or `nara.exe`). 21 + 22 + ### 2. Export books from Minecraft 23 + 24 + 1. Install the [Infinity Item Editor][iie] mod for Minecraft 1.12.2. 25 + 26 + 2. In Minecraft (with the mod installed), put your written books into Shulker Boxes (the colour of the Shulker Boxes does not matter). 27 + 28 + 3. Rename each shulker box in an anvil as: 29 + 30 + `CATEGORY_NAME ID` 31 + 32 + Example for a category named “Fiction”: 33 + 34 + `Fiction 1`, `Fiction 2`, `Fiction 3` 35 + 36 + 4. Save each shulker box using Infinity Item Editor (default keybind: `G`, configurable in Controls). 37 + 38 + Infinity Item Editor writes exports into `realm.nbt`. 39 + 40 + ### 3. Run 41 + 42 + 1. Make a new directory to hold both the app and the exported data. 43 + 2. Copy the `nara` binary into it. 44 + 3. Copy `realm.nbt` into it from your Minecraft directory: 45 + 46 + * **Windows:** `%AppData%\.minecraft\infinity-data\realm.nbt` 47 + * **Linux:** `~/.minecraft/infinity-data/realm.nbt` 48 + * **macOS:** `~/Library/Application Support/minecraft/infinity-data/realm.nbt` 49 + 50 + **macOS/Linux** 51 + 52 + ```sh 53 + ./nara 54 + ``` 55 + 56 + **Windows** 57 + 58 + ```bat 59 + nara.exe 60 + ``` 61 + 62 + nara will print a local URL in the terminal - open it in your browser. 63 + 64 + ## Customization 65 + 66 + All customization is via CLI flags: 67 + 68 + ```sh 69 + ./nara --help 70 + ``` 71 + 72 + Open an issue if anything is unclear. 73 + 74 + [rust]: https://rustup.rs/ "rustup.rs - the Rust toolchain installer" 75 + [mc]: https://minecraft.net/ "Minecraft" 76 + [forge]: https://files.minecraftforge.net/net/minecraftforge/forge/index_1.12.2.html "Downloads for Minecraft Forge for 1.12.2" 77 + [iie]: https://modrinth.com/mod/infinity-item-editor/version/0.15 "modrinth.com | 0.15 - Infinity Item Editor"
+23
build.rs
··· 1 + use std::process::Command; 2 + 3 + fn main() { 4 + let hash = Command::new("git") 5 + .args(["rev-parse", "--short=6", "HEAD"]) 6 + .output() 7 + .ok() 8 + .and_then(|out| { 9 + if out.status.success() { 10 + String::from_utf8(out.stdout).ok() 11 + } else { 12 + None 13 + } 14 + }) 15 + .map(|s| s.trim().to_string()) 16 + .filter(|s| !s.is_empty()) 17 + .unwrap_or_else(|| "unknown".to_string()); 18 + 19 + println!("cargo:rustc-env=NARA_GIT_HASH={}", hash); 20 + 21 + println!("cargo:rerun-if-changed=.git/HEAD"); 22 + println!("cargo:rerun-if-changed=.git/refs"); 23 + }
+26
justfile
··· 1 + @default: 2 + just --list 3 + 4 + alias r := run 5 + 6 + run *args: 7 + @cargo run -- {{ args }} 8 + 9 + alias rr := run-r 10 + 11 + # Run in release mode 12 + run-r *args: 13 + @cargo run --release -- {{ args }} 14 + 15 + lint: 16 + @cargo clippy 17 + 18 + alias b := build 19 + 20 + build: 21 + @cargo build --release 22 + 23 + test: 24 + @cargo test 25 + 26 + ok: lint test
+1
rustfmt.toml
··· 1 + max_width = 80
+46
src/cli.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use clap::{ArgAction, Parser}; 4 + 5 + use crate::web::TextureKind; 6 + 7 + #[derive(Parser, Debug)] 8 + #[command(version, about, long_about = None)] 9 + pub struct Args { 10 + /// The path to the `realm.nbt` file created by Infinity Item Editor. 11 + #[arg(short = 'r', long = "realm", default_value = "realm.nbt")] 12 + pub realm_path: PathBuf, 13 + 14 + /// Whether to warn about duplicate books being found during initial indexing. 15 + #[arg(short = 'd', long, default_value_t = true, action = ArgAction::Set)] 16 + pub warn_duplicates: bool, 17 + /// Whether to warn about empty/whitespace books being skipped during indexing. 18 + #[arg(short = 'e', long, default_value_t = true, action = ArgAction::Set)] 19 + pub warn_empty: bool, 20 + /// Whether to skip indexing books whose plain-text content is empty/whitespace. 21 + #[arg(short = 'f', long, default_value_t = true, action = ArgAction::Set)] 22 + pub filter_empty_books: bool, 23 + 24 + /// The score threshold for fuzzy finding book content. 25 + #[arg(short = 'C', long, default_value_t = 0.78)] 26 + pub content_threshold: f64, 27 + /// The score threshold for fuzzy finding book titles. 28 + #[arg(short = 'T', long, default_value_t = 0.80)] 29 + pub title_threshold: f64, 30 + /// The score threshold for fuzzy finding book authors. 31 + #[arg(short = 'A', long, default_value_t = 0.82)] 32 + pub author_threshold: f64, 33 + 34 + #[arg(short = 's', long = "dont-start-webserver", action = ArgAction::SetFalse)] 35 + pub start_webserver: bool, 36 + /// Address to bind the webserver to. 37 + #[arg( 38 + short = 'a', 39 + long = "host-address", 40 + default_value = "127.0.0.1:3000" 41 + )] 42 + pub webserver_host_address: String, 43 + /// Minecraft block/item texture variants to serve by default. 44 + #[arg(short, long, value_enum, default_value = "modern")] 45 + pub ui_textures: TextureKind, 46 + }
+814
src/library.rs
··· 1 + use std::time::{SystemTime, SystemTimeError}; 2 + 3 + use ahash::{AHashMap, AHashSet}; 4 + use lru::LruCache; 5 + use smol_str::SmolStr; 6 + use std::{cell::RefCell, num::NonZeroUsize}; 7 + use strsim::jaro_winkler; 8 + 9 + use serde::{Deserialize, Serialize}; 10 + 11 + /// Item model types parsed from realm data. 12 + pub mod item; 13 + pub mod text; 14 + 15 + use item::{ItemStack, WrittenBookTag}; 16 + use text::{Component, normalize_page, scrub_component, scrub_unwanted_glyphs}; 17 + 18 + pub type BookHash = [u8; 20]; 19 + pub type BookId = usize; 20 + 21 + /// Raw realm export data (top-level list of items). 22 + #[derive(Debug, Serialize, Deserialize)] 23 + pub struct Realm { 24 + pub realm: Vec<ItemStack>, 25 + pub realm_version: String, 26 + } 27 + 28 + /// In-memory index of all books with lookup and fuzzy search helpers. 29 + #[derive(Debug)] 30 + pub struct Library { 31 + books: Vec<WrittenBookTag>, 32 + 33 + by_hash: AHashMap<BookHash, BookId>, 34 + location_by_hash: AHashMap<BookHash, SmolStr>, 35 + by_category: AHashMap<SmolStr, Vec<BookId>>, 36 + by_author_lc: AHashMap<SmolStr, Vec<BookId>>, 37 + 38 + // normalized blobs for scoring (same index as `books`) 39 + norm_title: Vec<String>, 40 + norm_author: Vec<String>, 41 + norm_contents: Vec<String>, 42 + 43 + // trigram inverted indices -> candidate generation 44 + tri_title: AHashMap<u32, Vec<BookId>>, 45 + tri_author: AHashMap<u32, Vec<BookId>>, 46 + tri_contents: AHashMap<u32, Vec<BookId>>, 47 + 48 + content_threshold: f64, 49 + author_threshold: f64, 50 + title_threshold: f64, 51 + 52 + cache_books_by_author: RefCell<LruCache<SmolStr, Vec<BookId>>>, 53 + cache_books_in_category: RefCell<LruCache<SmolStr, Vec<BookId>>>, 54 + cache_fuzzy_title: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 55 + cache_fuzzy_author: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 56 + cache_fuzzy_contents: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 57 + cache_fuzzy_all: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 58 + 59 + duplicate_books_filtered: u16, 60 + empty_books_filtered: u16, 61 + } 62 + 63 + /// Cache key for fuzzy searches. 64 + #[derive(Debug, Clone, Hash, PartialEq, Eq)] 65 + struct FuzzyKey { 66 + query: SmolStr, 67 + limit: usize, 68 + } 69 + 70 + #[derive(Debug, thiserror::Error)] 71 + pub enum LibraryError { 72 + #[error("Book has no location: {0:?}")] 73 + MissingLocation(WrittenBookTag), 74 + #[error("Unsupported realm version '{0}'")] 75 + UnsupportedRealmVersion(String), 76 + #[error(transparent)] 77 + SystemTimeError(#[from] SystemTimeError), 78 + } 79 + 80 + pub type Result<T> = std::result::Result<T, LibraryError>; 81 + 82 + impl Library { 83 + /// Builds a library index from a realm export. 84 + pub fn new( 85 + realm: Realm, 86 + content_threshold: f64, 87 + author_threshold: f64, 88 + title_threshold: f64, 89 + warn_duplicates: bool, 90 + warn_empty: bool, 91 + filter_empty_books: bool, 92 + ) -> Result<Library> { 93 + if realm.realm_version != "0.2" { 94 + return Err(LibraryError::UnsupportedRealmVersion( 95 + realm.realm_version, 96 + )); 97 + } 98 + 99 + let start = SystemTime::now(); 100 + let mut library = Library { 101 + books: Vec::new(), 102 + by_hash: AHashMap::new(), 103 + location_by_hash: AHashMap::new(), 104 + by_category: AHashMap::new(), 105 + by_author_lc: AHashMap::new(), 106 + norm_title: Vec::new(), 107 + norm_author: Vec::new(), 108 + norm_contents: Vec::new(), 109 + tri_title: AHashMap::new(), 110 + tri_author: AHashMap::new(), 111 + tri_contents: AHashMap::new(), 112 + content_threshold, 113 + author_threshold, 114 + title_threshold, 115 + cache_books_by_author: RefCell::new(new_lru(CACHE_BY_AUTHOR_CAP)), 116 + cache_books_in_category: RefCell::new(new_lru( 117 + CACHE_BY_CATEGORY_CAP, 118 + )), 119 + cache_fuzzy_title: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 120 + cache_fuzzy_author: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 121 + cache_fuzzy_contents: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 122 + cache_fuzzy_all: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 123 + duplicate_books_filtered: 0, 124 + empty_books_filtered: 0, 125 + }; 126 + 127 + fn traverse_items( 128 + items: Vec<ItemStack>, 129 + current_location: Option<SmolStr>, 130 + library: &mut Library, 131 + warn_duplicates: bool, 132 + warn_empty: bool, 133 + filter_empty_books: bool, 134 + ) -> Result<()> { 135 + for item in items { 136 + match item { 137 + ItemStack::WrittenBook { tag, .. } => { 138 + library.add_book( 139 + tag, 140 + current_location.clone(), 141 + warn_duplicates, 142 + warn_empty, 143 + filter_empty_books, 144 + )?; 145 + } 146 + ItemStack::ShulkerBox { tag, .. } => { 147 + let shulker_location = tag 148 + .display 149 + .as_ref() 150 + .and_then(|d| d.name.as_deref()) 151 + .map(SmolStr::new); 152 + 153 + let next_location = shulker_location 154 + .or_else(|| current_location.clone()); 155 + traverse_items( 156 + tag.block_entity.items, 157 + next_location, 158 + library, 159 + warn_duplicates, 160 + warn_empty, 161 + filter_empty_books, 162 + )?; 163 + } 164 + } 165 + } 166 + Ok(()) 167 + } 168 + 169 + traverse_items( 170 + realm.realm, 171 + None, 172 + &mut library, 173 + warn_duplicates, 174 + warn_empty, 175 + filter_empty_books, 176 + )?; 177 + 178 + let elapsed_ms = SystemTime::now().duration_since(start)?.as_nanos() 179 + as f64 180 + / 1_000_000.0; 181 + tracing::info!( 182 + "Indexed {0} books in {1} categories in {2}ms (filtered {3} duplicates, {4} empty)", 183 + library.books.len(), 184 + library.by_category.keys().len(), 185 + elapsed_ms, 186 + library.duplicate_books_filtered, 187 + library.empty_books_filtered, 188 + ); 189 + Ok(library) 190 + } 191 + 192 + /// Inserts a book and updates all indices and caches. 193 + fn add_book( 194 + &mut self, 195 + mut book: WrittenBookTag, 196 + location: Option<SmolStr>, 197 + warn_duplicates: bool, 198 + warn_empty: bool, 199 + filter_empty_books: bool, 200 + ) -> Result<()> { 201 + book.title = scrub_unwanted_glyphs(&book.title); 202 + book.author = scrub_unwanted_glyphs(&book.author); 203 + for page in &mut book.pages { 204 + scrub_component(page); 205 + } 206 + if filter_empty_books && book_plain_text_empty(&book) { 207 + if warn_empty { 208 + tracing::warn!( 209 + "Skipping empty book in location {0:?}: {1} by {2}", 210 + location, 211 + book.title, 212 + book.author 213 + ); 214 + } 215 + self.empty_books_filtered += 1; 216 + return Ok(()); 217 + } 218 + 219 + let Some(location) = location else { 220 + return Err(LibraryError::MissingLocation(book)); 221 + }; 222 + 223 + let Some(category) = category_from_location(&location) else { 224 + return Err(LibraryError::MissingLocation(book)); 225 + }; 226 + 227 + let h = book.hash(); 228 + if self.by_hash.contains_key(&h) { 229 + if warn_duplicates { 230 + let existing_book_location = 231 + self.location_by_hash.get(&h).expect("book to exist"); 232 + tracing::warn!( 233 + "Duplicate book in location {0:?}: {1} by {2} [already in {3}]", 234 + location, 235 + book.title, 236 + book.author, 237 + existing_book_location 238 + ); 239 + } 240 + self.duplicate_books_filtered += 1; 241 + return Ok(()); 242 + } 243 + 244 + let id = self.books.len(); 245 + self.books.push(book); 246 + 247 + // indices... 248 + self.by_hash.insert(h, id); 249 + self.by_category 250 + .entry(category.clone()) 251 + .or_default() 252 + .push(id); 253 + self.location_by_hash.insert(h, location); 254 + 255 + let author_lc = SmolStr::new(normalize(&self.books[id].author)); 256 + if !author_lc.is_empty() { 257 + self.by_author_lc.entry(author_lc).or_default().push(id); 258 + } 259 + 260 + // normalized blobs (for scoring) 261 + self.norm_title.push(normalize(&self.books[id].title)); 262 + self.norm_author.push(normalize(&self.books[id].author)); 263 + self.norm_contents 264 + .push(normalize_contents(&self.books[id].pages)); 265 + 266 + // candidate-generation indices 267 + index_trigrams(&mut self.tri_title, id, &self.norm_title[id]); 268 + index_trigrams(&mut self.tri_author, id, &self.norm_author[id]); 269 + index_trigrams(&mut self.tri_contents, id, &self.norm_contents[id]); 270 + 271 + self.cache_books_by_author.borrow_mut().clear(); 272 + self.cache_books_in_category.borrow_mut().clear(); 273 + self.cache_fuzzy_title.borrow_mut().clear(); 274 + self.cache_fuzzy_author.borrow_mut().clear(); 275 + self.cache_fuzzy_contents.borrow_mut().clear(); 276 + self.cache_fuzzy_all.borrow_mut().clear(); 277 + 278 + Ok(()) 279 + } 280 + 281 + /// Looks up a book by its content hash. 282 + #[inline] 283 + pub fn book_by_hash(&self, hash: BookHash) -> Option<&WrittenBookTag> { 284 + self.by_hash.get(&hash).map(|&id| &self.books[id]) 285 + } 286 + 287 + /// Lists books for an author (case-insensitive match). 288 + #[inline] 289 + pub fn books_by_author<'a>( 290 + &'a self, 291 + author: &str, 292 + ) -> impl Iterator<Item = &'a WrittenBookTag> + 'a { 293 + let key = SmolStr::new(normalize(author)); 294 + let ids = if key.is_empty() { 295 + Vec::new() 296 + } else if let Some(ids) = 297 + self.cache_books_by_author.borrow_mut().get(&key).cloned() 298 + { 299 + ids 300 + } else { 301 + let ids = self.by_author_lc.get(&key).cloned().unwrap_or_default(); 302 + self.cache_books_by_author 303 + .borrow_mut() 304 + .put(key.clone(), ids.clone()); 305 + ids 306 + }; 307 + 308 + ids.into_iter().map(|id| &self.books[id]) 309 + } 310 + 311 + /// Lists books by category derived from location strings. 312 + #[inline] 313 + pub fn books_in_category<'a>( 314 + &'a self, 315 + category: &str, 316 + ) -> impl Iterator<Item = &'a WrittenBookTag> + 'a { 317 + let key = SmolStr::new(category); 318 + let ids = if key.is_empty() { 319 + Vec::new() 320 + } else if let Some(ids) = 321 + self.cache_books_in_category.borrow_mut().get(&key).cloned() 322 + { 323 + ids 324 + } else { 325 + let ids = self.by_category.get(&key).cloned().unwrap_or_default(); 326 + self.cache_books_in_category 327 + .borrow_mut() 328 + .put(key.clone(), ids.clone()); 329 + ids 330 + }; 331 + 332 + ids.into_iter().map(|id| &self.books[id]) 333 + } 334 + 335 + /// Fuzzy search over normalized titles. 336 + pub fn fuzzy_title( 337 + &self, 338 + query: &str, 339 + limit: usize, 340 + ) -> Vec<(&WrittenBookTag, f64)> { 341 + let key = SmolStr::new(normalize(query)); 342 + if key.is_empty() || limit == 0 { 343 + return Vec::new(); 344 + } 345 + 346 + let cache_key = FuzzyKey { 347 + query: key.clone(), 348 + limit, 349 + }; 350 + 351 + let scored = if let Some(scored) = 352 + self.cache_fuzzy_title.borrow_mut().get(&cache_key).cloned() 353 + { 354 + scored 355 + } else { 356 + let scored = fuzzy_rank( 357 + key.as_str(), 358 + &self.norm_title, 359 + &self.tri_title, 360 + limit, 361 + self.title_threshold, 362 + ); 363 + self.cache_fuzzy_title 364 + .borrow_mut() 365 + .put(cache_key, scored.clone()); 366 + scored 367 + }; 368 + 369 + scored 370 + .into_iter() 371 + .map(|(id, s)| (&self.books[id], s)) 372 + .collect() 373 + } 374 + 375 + /// Fuzzy search over normalized author names. 376 + pub fn fuzzy_author( 377 + &self, 378 + query: &str, 379 + limit: usize, 380 + ) -> Vec<(&WrittenBookTag, f64)> { 381 + let key = SmolStr::new(normalize(query)); 382 + if key.is_empty() || limit == 0 { 383 + return Vec::new(); 384 + } 385 + 386 + let cache_key = FuzzyKey { 387 + query: key.clone(), 388 + limit, 389 + }; 390 + 391 + let scored = if let Some(scored) = self 392 + .cache_fuzzy_author 393 + .borrow_mut() 394 + .get(&cache_key) 395 + .cloned() 396 + { 397 + scored 398 + } else { 399 + let scored = fuzzy_rank( 400 + key.as_str(), 401 + &self.norm_author, 402 + &self.tri_author, 403 + limit, 404 + self.author_threshold, 405 + ); 406 + self.cache_fuzzy_author 407 + .borrow_mut() 408 + .put(cache_key, scored.clone()); 409 + scored 410 + }; 411 + 412 + scored 413 + .into_iter() 414 + .map(|(id, s)| (&self.books[id], s)) 415 + .collect() 416 + } 417 + 418 + /// Fuzzy search over normalized contents blobs. 419 + pub fn fuzzy_contents( 420 + &self, 421 + query: &str, 422 + limit: usize, 423 + ) -> Vec<(&WrittenBookTag, f64)> { 424 + let key = SmolStr::new(normalize(query)); 425 + if key.is_empty() || limit == 0 { 426 + return Vec::new(); 427 + } 428 + 429 + let cache_key = FuzzyKey { 430 + query: key.clone(), 431 + limit, 432 + }; 433 + 434 + let scored = if let Some(scored) = self 435 + .cache_fuzzy_contents 436 + .borrow_mut() 437 + .get(&cache_key) 438 + .cloned() 439 + { 440 + scored 441 + } else { 442 + let scored = fuzzy_rank_contents( 443 + key.as_str(), 444 + &self.norm_contents, 445 + &self.tri_contents, 446 + limit, 447 + self.content_threshold, 448 + ); 449 + self.cache_fuzzy_contents 450 + .borrow_mut() 451 + .put(cache_key, scored.clone()); 452 + scored 453 + }; 454 + 455 + scored 456 + .into_iter() 457 + .map(|(id, s)| (&self.books[id], s)) 458 + .collect() 459 + } 460 + 461 + /// Combined fuzzy search (title + author + contents). 462 + pub fn fuzzy( 463 + &self, 464 + query: &str, 465 + limit: usize, 466 + ) -> Vec<(&WrittenBookTag, f64)> { 467 + let key = SmolStr::new(normalize(query)); 468 + if key.is_empty() || limit == 0 { 469 + return Vec::new(); 470 + } 471 + 472 + let cache_key = FuzzyKey { 473 + query: key.clone(), 474 + limit, 475 + }; 476 + 477 + let scored = if let Some(scored) = 478 + self.cache_fuzzy_all.borrow_mut().get(&cache_key).cloned() 479 + { 480 + scored 481 + } else { 482 + let mut totals: AHashMap<BookId, f64> = AHashMap::new(); 483 + 484 + let title = fuzzy_rank( 485 + key.as_str(), 486 + &self.norm_title, 487 + &self.tri_title, 488 + (limit * 4).clamp(50, 2000), 489 + self.title_threshold, 490 + ); 491 + for (id, s) in title { 492 + *totals.entry(id).or_insert(0.0) += s; 493 + } 494 + 495 + let author = fuzzy_rank( 496 + key.as_str(), 497 + &self.norm_author, 498 + &self.tri_author, 499 + (limit * 4).clamp(50, 2000), 500 + self.author_threshold, 501 + ); 502 + for (id, s) in author { 503 + *totals.entry(id).or_insert(0.0) += s; 504 + } 505 + 506 + let contents = fuzzy_rank_contents( 507 + key.as_str(), 508 + &self.norm_contents, 509 + &self.tri_contents, 510 + (limit * 6).clamp(100, 4000), 511 + self.content_threshold, 512 + ); 513 + for (id, s) in contents { 514 + *totals.entry(id).or_insert(0.0) += s * 0.7; 515 + } 516 + 517 + let mut scored: Vec<(BookId, f64)> = totals.into_iter().collect(); 518 + scored.sort_by(|a, b| { 519 + b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal) 520 + }); 521 + scored.truncate(limit); 522 + 523 + self.cache_fuzzy_all 524 + .borrow_mut() 525 + .put(cache_key, scored.clone()); 526 + scored 527 + }; 528 + 529 + scored 530 + .into_iter() 531 + .map(|(id, s)| (&self.books[id], s)) 532 + .collect() 533 + } 534 + 535 + /// Returns the number of indexed books. 536 + #[inline] 537 + pub fn book_count(&self) -> usize { 538 + self.books.len() 539 + } 540 + 541 + /// Returns a list of all books in the library. 542 + #[inline] 543 + pub fn all_books<'a>( 544 + &'a self, 545 + ) -> impl Iterator<Item = &'a WrittenBookTag> + 'a { 546 + self.books.iter() 547 + } 548 + 549 + /// Returns all categories with their book counts. 550 + #[inline] 551 + pub fn categories(&self) -> Vec<(SmolStr, usize)> { 552 + self.by_category 553 + .iter() 554 + .map(|(k, v)| (k.clone(), v.len())) 555 + .collect() 556 + } 557 + 558 + /// Returns the location string for a book hash, if present. 559 + #[inline] 560 + pub fn location_for_hash(&self, hash: &BookHash) -> Option<&SmolStr> { 561 + self.location_by_hash.get(hash) 562 + } 563 + } 564 + 565 + /// Lowercases and normalizes a query string. 566 + #[inline] 567 + fn normalize(s: &str) -> String { 568 + s.to_lowercase() 569 + } 570 + 571 + fn book_plain_text_empty(book: &WrittenBookTag) -> bool { 572 + book.pages.is_empty() 573 + || book 574 + .pages 575 + .iter() 576 + .all(|page| normalize_page(page).trim().is_empty()) 577 + } 578 + 579 + const CACHE_BY_AUTHOR_CAP: usize = 1024; 580 + const CACHE_BY_CATEGORY_CAP: usize = 1024; 581 + const CACHE_FUZZY_CAP: usize = 256; 582 + 583 + /// Helper to build LRU caches with non-zero capacity. 584 + fn new_lru<K: std::hash::Hash + Eq, V>(cap: usize) -> LruCache<K, V> { 585 + LruCache::new(NonZeroUsize::new(cap).expect("cache cap must be > 0")) 586 + } 587 + 588 + /// Extracts category prefix from a location string. 589 + pub(crate) fn category_from_location(location: &str) -> Option<SmolStr> { 590 + let first_digit = location.find(|c: char| c.is_ascii_digit())?; 591 + let category = location[..first_digit].trim_end(); 592 + if category.is_empty() { 593 + None 594 + } else { 595 + Some(SmolStr::new(category)) 596 + } 597 + } 598 + 599 + const MAX_CONTENT_INDEX_CHARS: usize = 16_384; 600 + 601 + /// Joins and normalizes pages for the contents index. 602 + fn normalize_contents(pages: &[Component]) -> String { 603 + let mut out = String::new(); 604 + for p in pages { 605 + if out.len() >= MAX_CONTENT_INDEX_CHARS { 606 + break; 607 + } 608 + if !out.is_empty() { 609 + out.push('\n'); 610 + } 611 + out.push_str(&p.to_plain_text()); 612 + } 613 + let mut out = out.to_lowercase(); 614 + if out.len() > MAX_CONTENT_INDEX_CHARS { 615 + out.truncate(MAX_CONTENT_INDEX_CHARS); 616 + } 617 + out 618 + } 619 + 620 + #[inline] 621 + fn query_terms(q: &str) -> Vec<&str> { 622 + q.split_whitespace().filter(|t| !t.is_empty()).collect() 623 + } 624 + 625 + #[inline] 626 + fn token_coverage(terms: &[&str], haystack: &str) -> f64 { 627 + if terms.is_empty() { 628 + return 0.0; 629 + } 630 + let matched = terms.iter().filter(|t| haystack.contains(**t)).count(); 631 + matched as f64 / terms.len() as f64 632 + } 633 + 634 + /// Encodes ASCII-ish trigrams into `u32`. 635 + fn trigrams(s: &str) -> AHashSet<u32> { 636 + let b = s.as_bytes(); 637 + let mut set = AHashSet::new(); 638 + 639 + if b.is_empty() { 640 + return set; 641 + } 642 + 643 + if b.len() < 3 { 644 + let b0 = b.first().copied().unwrap_or(0); 645 + let b1 = b.get(1).copied().unwrap_or(0); 646 + let tri = ((b0 as u32) << 16) | ((b1 as u32) << 8); 647 + set.insert(tri); 648 + return set; 649 + } 650 + 651 + for w in b.windows(3) { 652 + let tri = ((w[0] as u32) << 16) | ((w[1] as u32) << 8) | (w[2] as u32); 653 + set.insert(tri); 654 + } 655 + 656 + set 657 + } 658 + 659 + /// Adds all trigrams from a string to the inverted index. 660 + fn index_trigrams( 661 + index: &mut AHashMap<u32, Vec<BookId>>, 662 + id: BookId, 663 + norm: &str, 664 + ) { 665 + for tri in trigrams(norm) { 666 + index.entry(tri).or_default().push(id); 667 + } 668 + } 669 + 670 + /// Shared pipeline for fuzzy ranking with a custom scoring function. 671 + fn fuzzy_rank_with<F>( 672 + query: &str, 673 + norm_field: &[String], 674 + tri_index: &AHashMap<u32, Vec<BookId>>, 675 + limit: usize, 676 + score_threshold: f64, 677 + max_to_score: usize, 678 + mut score_fn: F, 679 + ) -> Vec<(BookId, f64)> 680 + where 681 + F: FnMut(BookId, u32, &str, &AHashSet<u32>, &str, &[&str]) -> f64, 682 + { 683 + let q = normalize(query); 684 + if q.is_empty() || limit == 0 { 685 + return Vec::new(); 686 + } 687 + 688 + let q_tris = trigrams(&q); 689 + let terms = query_terms(&q); 690 + let mut counts: AHashMap<BookId, u32> = AHashMap::new(); 691 + 692 + for tri in q_tris.iter() { 693 + if let Some(ids) = tri_index.get(tri) { 694 + for &id in ids { 695 + *counts.entry(id).or_insert(0) += 1; 696 + } 697 + } 698 + } 699 + 700 + // Always include obvious direct matches so they don't disappear due 701 + // trigram candidate generation edge-cases. 702 + for (id, s_norm) in norm_field.iter().enumerate() { 703 + let contains_query = s_norm.contains(&q); 704 + let has_all_terms = 705 + !terms.is_empty() && token_coverage(&terms, s_norm) == 1.0; 706 + if contains_query { 707 + *counts.entry(id).or_insert(0) += 10_000; 708 + } 709 + if has_all_terms { 710 + *counts.entry(id).or_insert(0) += 5_000; 711 + } 712 + } 713 + 714 + let candidates: Vec<(BookId, u32)> = if q.len() < 3 || counts.is_empty() { 715 + (0..norm_field.len()).map(|id| (id, 0)).collect() 716 + } else { 717 + let mut v: Vec<(BookId, u32)> = counts.into_iter().collect(); 718 + v.sort_by(|a, b| b.1.cmp(&a.1)); 719 + v.truncate(max_to_score); 720 + v 721 + }; 722 + 723 + let mut scored: Vec<(BookId, f64)> = Vec::with_capacity(candidates.len()); 724 + for (id, overlap) in candidates { 725 + let s_norm = &norm_field[id]; 726 + let score = score_fn(id, overlap, s_norm, &q_tris, &q, &terms); 727 + if score >= (score_threshold * 0.75) || s_norm.contains(&q) { 728 + scored.push((id, score)); 729 + } 730 + } 731 + 732 + scored.sort_by(|a, b| { 733 + b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal) 734 + }); 735 + scored.truncate(limit); 736 + 737 + scored 738 + } 739 + 740 + /// Fuzzy rank using Jaro-Winkler on the normalized field. 741 + fn fuzzy_rank( 742 + query: &str, 743 + norm_field: &[String], 744 + tri_index: &AHashMap<u32, Vec<BookId>>, 745 + limit: usize, 746 + score_threshold: f64, 747 + ) -> Vec<(BookId, f64)> { 748 + let max_to_score = (limit * 30).clamp(50, 5000); 749 + fuzzy_rank_with( 750 + query, 751 + norm_field, 752 + tri_index, 753 + limit, 754 + score_threshold, 755 + max_to_score, 756 + |_, overlap, s_norm, q_tris, q, terms| { 757 + let overlap_score = if q_tris.is_empty() { 758 + 0.0 759 + } else { 760 + overlap as f64 / q_tris.len() as f64 761 + }; 762 + let coverage = token_coverage(terms, s_norm); 763 + let mut score = (jaro_winkler(q, s_norm) * 0.45) 764 + + (overlap_score * 0.25) 765 + + (coverage * 0.45); 766 + if s_norm == q { 767 + score += 1.0; 768 + } 769 + if s_norm.starts_with(q) { 770 + score += 0.45; 771 + } 772 + if s_norm.contains(q) { 773 + score += 0.6; 774 + } 775 + if !terms.is_empty() && coverage == 1.0 { 776 + score += 0.5; 777 + } 778 + score 779 + }, 780 + ) 781 + } 782 + 783 + /// Fuzzy rank using trigram overlap against contents blobs. 784 + fn fuzzy_rank_contents( 785 + query: &str, 786 + norm_field: &[String], 787 + tri_index: &ahash::AHashMap<u32, Vec<BookId>>, 788 + limit: usize, 789 + score_threshold: f64, 790 + ) -> Vec<(BookId, f64)> { 791 + let max_to_score = (limit * 50).clamp(100, 10_000); 792 + fuzzy_rank_with( 793 + query, 794 + norm_field, 795 + tri_index, 796 + limit, 797 + score_threshold, 798 + max_to_score, 799 + |_, overlap, s_norm, q_tris, q, terms| { 800 + let q_tri_len = q_tris.len().max(1) as f64; 801 + let overlap_score = (overlap as f64) / q_tri_len; 802 + let coverage = token_coverage(terms, s_norm); 803 + let mut score = (overlap_score * 0.55) + (coverage * 0.75); 804 + if s_norm.contains(q) { 805 + score += 0.9; 806 + } 807 + if !terms.is_empty() && coverage == 1.0 { 808 + score += 0.5; 809 + } 810 + score += jaro_winkler(q, s_norm).min(0.75) * 0.15; 811 + score 812 + }, 813 + ) 814 + }
+131
src/library/item.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use serde_with::skip_serializing_none; 3 + use sha1::Digest; 4 + 5 + use crate::library::{ 6 + BookHash, 7 + text::{Component, normalize_page}, 8 + }; 9 + 10 + /// Top-level item container parsed from realm exports. 11 + #[skip_serializing_none] 12 + #[derive(Debug, Clone, Serialize, Deserialize)] 13 + #[serde(tag = "id", rename_all_fields = "PascalCase")] 14 + pub enum ItemStack { 15 + /// A written book stack with the data we index. 16 + #[serde(rename = "minecraft:written_book")] 17 + WrittenBook { 18 + count: i8, 19 + damage: i16, 20 + slot: Option<i8>, 21 + #[serde(rename = "tag")] 22 + tag: WrittenBookTag, 23 + }, 24 + /// A shulker box that can contain nested items. 25 + #[serde( 26 + rename = "minecraft:red_shulker_box", 27 + alias = "minecraft:orange_shulker_box", 28 + alias = "minecraft:yellow_shulker_box", 29 + alias = "minecraft:lime_shulker_box", 30 + alias = "minecraft:green_shulker_box", 31 + alias = "minecraft:cyan_shulker_box", 32 + alias = "minecraft:light_blue_shulker_box", 33 + alias = "minecraft:blue_shulker_box", 34 + alias = "minecraft:purple_shulker_box", 35 + alias = "minecraft:magenta_shulker_box", 36 + alias = "minecraft:pink_shulker_box", 37 + alias = "minecraft:brown_shulker_box", 38 + alias = "minecraft:black_shulker_box", 39 + alias = "minecraft:gray_shulker_box", 40 + alias = "minecraft:light_gray_shulker_box", 41 + alias = "minecraft:white_shulker_box", 42 + alias = "minecraft:shulker_box" 43 + )] 44 + ShulkerBox { 45 + count: i8, 46 + damage: i16, 47 + slot: Option<i8>, 48 + #[serde(rename = "tag")] 49 + tag: ShulkerBoxTag, 50 + }, 51 + } 52 + 53 + /// NBT tag data for a written book. 54 + #[skip_serializing_none] 55 + #[derive(Debug, Clone, Serialize, Deserialize)] 56 + pub struct WrittenBookTag { 57 + pub display: Option<DisplayTag>, 58 + 59 + #[serde(rename = "author")] 60 + pub author: String, 61 + 62 + #[serde(rename = "title")] 63 + pub title: String, 64 + 65 + #[serde(rename = "generation")] 66 + pub generation: Option<i32>, 67 + 68 + #[serde(rename = "resolved")] 69 + pub resolved: Option<i8>, 70 + 71 + #[serde(rename = "pages", default)] 72 + pub pages: Vec<Component>, 73 + } 74 + 75 + impl WrittenBookTag { 76 + /// Computes the hash of the book's contents. 77 + /// 78 + /// Metadata is not taken into account when calculating the hash. The hash should _only_ be used 79 + /// to quantify "content uniqueness". 80 + pub fn hash(&self) -> BookHash { 81 + let mut ctx = sha1::Sha1::new(); 82 + 83 + #[inline(always)] 84 + fn put_str(ctx: &mut sha1::Sha1, s: &str) { 85 + ctx.update(s.as_bytes()); 86 + } 87 + 88 + #[inline(always)] 89 + fn put_vec_page(ctx: &mut sha1::Sha1, v: &[Component]) { 90 + for c in v { 91 + let s = normalize_page(c); 92 + put_str(ctx, &s); 93 + } 94 + } 95 + 96 + put_str(&mut ctx, &self.author); 97 + put_str(&mut ctx, &self.title); 98 + put_vec_page(&mut ctx, &self.pages); 99 + 100 + ctx.finalize().0 101 + } 102 + } 103 + 104 + /// NBT tag data for a shulker box. 105 + #[skip_serializing_none] 106 + #[derive(Debug, Clone, Serialize, Deserialize)] 107 + pub struct ShulkerBoxTag { 108 + pub display: Option<DisplayTag>, 109 + 110 + #[serde(rename = "BlockEntityTag")] 111 + pub block_entity: ShulkerBlockEntityTag, 112 + } 113 + 114 + /// Block entity payload for a shulker box, containing its items. 115 + #[skip_serializing_none] 116 + #[derive(Debug, Clone, Serialize, Deserialize)] 117 + #[serde(rename_all = "PascalCase")] 118 + pub struct ShulkerBlockEntityTag { 119 + pub custom_name: Option<String>, 120 + 121 + #[serde(default)] 122 + pub items: Vec<ItemStack>, 123 + } 124 + 125 + /// Common display metadata (custom name, etc.). 126 + #[skip_serializing_none] 127 + #[derive(Debug, Clone, Serialize, Deserialize)] 128 + #[serde(rename_all = "PascalCase")] 129 + pub struct DisplayTag { 130 + pub name: Option<String>, 131 + }
+550
src/library/text.rs
··· 1 + use html_escape::encode_text; 2 + use serde::de::{ 3 + MapAccess, SeqAccess, Visitor, 4 + value::{MapAccessDeserializer, SeqAccessDeserializer}, 5 + }; 6 + use serde::{Deserialize, Deserializer, Serialize}; 7 + 8 + /// A simplified representation of Minecraft 1.12.2 text components. 9 + #[derive(Debug, Clone, Serialize)] 10 + #[serde(untagged)] 11 + pub enum Component { 12 + String(String), 13 + Object(ComponentObject), 14 + Array(Vec<Component>), 15 + } 16 + 17 + #[serde_with::skip_serializing_none] 18 + #[derive(Debug, Clone, Serialize, Deserialize)] 19 + pub struct ComponentObject { 20 + #[serde(default)] 21 + pub text: Option<String>, 22 + #[serde(default)] 23 + pub color: Option<String>, 24 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 25 + pub extra: Vec<Component>, 26 + #[serde(default)] 27 + pub translate: Option<String>, 28 + #[serde(default, rename = "with", skip_serializing_if = "Vec::is_empty")] 29 + pub with_: Vec<Component>, 30 + } 31 + 32 + impl Component { 33 + pub fn to_plain_text(&self) -> String { 34 + let mut out = String::new(); 35 + self.push_plain_text(&mut out); 36 + out 37 + } 38 + 39 + pub fn to_html(&self) -> String { 40 + let mut out = String::new(); 41 + self.push_html(&mut out, None); 42 + out 43 + } 44 + 45 + fn push_plain_text(&self, out: &mut String) { 46 + match self { 47 + Component::String(s) => out.push_str(s), 48 + Component::Array(items) => { 49 + for c in items { 50 + c.push_plain_text(out); 51 + } 52 + } 53 + Component::Object(obj) => { 54 + if let Some(t) = &obj.text { 55 + out.push_str(t); 56 + } else if let Some(t) = &obj.translate { 57 + out.push_str(t); 58 + } 59 + for c in &obj.with_ { 60 + c.push_plain_text(out); 61 + } 62 + for c in &obj.extra { 63 + c.push_plain_text(out); 64 + } 65 + } 66 + } 67 + } 68 + 69 + fn push_html<'a>(&'a self, out: &mut String, color: Option<&'a str>) { 70 + match self { 71 + Component::String(s) => { 72 + push_text_with_color(out, s, color); 73 + } 74 + Component::Array(items) => { 75 + for c in items { 76 + c.push_html(out, color); 77 + } 78 + } 79 + Component::Object(obj) => { 80 + let next_color = obj.color.as_deref().or(color); 81 + if let Some(t) = &obj.text { 82 + push_text_with_color(out, t, next_color); 83 + } else if let Some(t) = &obj.translate { 84 + push_text_with_color(out, t, next_color); 85 + } 86 + for c in &obj.with_ { 87 + c.push_html(out, next_color); 88 + } 89 + for c in &obj.extra { 90 + c.push_html(out, next_color); 91 + } 92 + } 93 + } 94 + } 95 + } 96 + 97 + const SCRUBBED_GLYPH: char = '\u{f702}'; 98 + 99 + /// Removes known unwanted glyphs from text content. 100 + pub fn scrub_unwanted_glyphs(raw: &str) -> String { 101 + raw.chars().filter(|&c| c != SCRUBBED_GLYPH).collect() 102 + } 103 + 104 + /// Recursively removes unwanted glyphs from a component tree. 105 + pub fn scrub_component(component: &mut Component) { 106 + match component { 107 + Component::String(s) => { 108 + *s = scrub_unwanted_glyphs(s); 109 + } 110 + Component::Array(items) => { 111 + for item in items { 112 + scrub_component(item); 113 + } 114 + } 115 + Component::Object(obj) => { 116 + if let Some(text) = &mut obj.text { 117 + *text = scrub_unwanted_glyphs(text); 118 + } 119 + if let Some(translate) = &mut obj.translate { 120 + *translate = scrub_unwanted_glyphs(translate); 121 + } 122 + for item in &mut obj.with_ { 123 + scrub_component(item); 124 + } 125 + for item in &mut obj.extra { 126 + scrub_component(item); 127 + } 128 + } 129 + } 130 + } 131 + 132 + /// Removes legacy section symbol formatting codes. 133 + fn strip_section_codes(s: &str) -> String { 134 + let mut out = String::with_capacity(s.len()); 135 + let mut chars = s.chars(); 136 + while let Some(c) = chars.next() { 137 + if c == SECTION_SIGN { 138 + // Skip formatting code character if present. 139 + chars.next(); 140 + continue; 141 + } 142 + out.push(c); 143 + } 144 + out 145 + } 146 + 147 + /// Normalizes a component for hashing. 148 + pub fn normalize_page(component: &Component) -> String { 149 + let text = component.to_plain_text(); 150 + strip_section_codes(&text) 151 + } 152 + 153 + /// Parses a page string into a component, handling stringified JSON. 154 + pub fn parse_component_from_str(raw: &str) -> Component { 155 + let mut current = raw.trim().to_string(); 156 + if current.is_empty() { 157 + return Component::String(String::new()); 158 + } 159 + if let Some(extracted) = extract_text_prefix(&current) { 160 + return expand_legacy(legacy_or_plain(&extracted)); 161 + } 162 + 163 + // Try parsing as JSON component once. 164 + if let Ok(component) = serde_json::from_str::<Component>(&current) { 165 + return expand_legacy(component); 166 + } 167 + 168 + // Try parsing as a JSON string, then re-parse if it was a JSON string that 169 + // itself contains JSON (stringified JSON). 170 + for _ in 0..3 { 171 + let s = match serde_json::from_str::<String>(&current) { 172 + Ok(v) => v, 173 + Err(_) => break, 174 + }; 175 + let s_trim = s.trim(); 176 + if looks_like_json(s_trim) { 177 + current = s_trim.to_string(); 178 + if let Ok(component) = serde_json::from_str::<Component>(&current) { 179 + return expand_legacy(component); 180 + } 181 + continue; 182 + } 183 + return expand_legacy(legacy_or_plain(&s)); 184 + } 185 + 186 + expand_legacy(legacy_or_plain(raw)) 187 + } 188 + 189 + fn looks_like_json(s: &str) -> bool { 190 + s.starts_with('{') || s.starts_with('[') 191 + } 192 + 193 + fn extract_text_prefix(s: &str) -> Option<String> { 194 + let s = s.trim_start(); 195 + if !s.starts_with("text") { 196 + return None; 197 + } 198 + let mut rest = s[4..].trim_start(); 199 + if rest.starts_with(':') { 200 + rest = rest[1..].trim_start(); 201 + } 202 + let start = rest.find('"')?; 203 + let rest = &rest[start..]; 204 + if let Ok(parsed) = serde_json::from_str::<String>(rest) { 205 + return Some(parsed); 206 + } 207 + if let Some(end) = rest[1..].rfind('"') { 208 + let slice = &rest[..end + 2]; 209 + if let Ok(parsed) = serde_json::from_str::<String>(slice) { 210 + return Some(parsed); 211 + } 212 + let content = &rest[1..end + 1]; 213 + return Some(unescape_json_string_lossy(content)); 214 + } 215 + None 216 + } 217 + 218 + fn unescape_json_string_lossy(s: &str) -> String { 219 + let mut out = String::with_capacity(s.len()); 220 + let mut chars = s.chars(); 221 + while let Some(c) = chars.next() { 222 + if c != '\\' { 223 + out.push(c); 224 + continue; 225 + } 226 + match chars.next() { 227 + Some('"') => out.push('"'), 228 + Some('\\') => out.push('\\'), 229 + Some('/') => out.push('/'), 230 + Some('b') => out.push('\u{0008}'), 231 + Some('f') => out.push('\u{000C}'), 232 + Some('n') => out.push('\n'), 233 + Some('r') => out.push('\r'), 234 + Some('t') => out.push('\t'), 235 + Some('u') => { 236 + let mut hex = String::new(); 237 + for _ in 0..4 { 238 + if let Some(h) = chars.next() { 239 + hex.push(h); 240 + } else { 241 + break; 242 + } 243 + } 244 + if let Ok(code) = u16::from_str_radix(&hex, 16) 245 + && let Some(ch) = char::from_u32(code as u32) 246 + { 247 + out.push(ch); 248 + } 249 + } 250 + Some(other) => out.push(other), 251 + None => break, 252 + } 253 + } 254 + out 255 + } 256 + 257 + fn legacy_or_plain(s: &str) -> Component { 258 + if s.contains(SECTION_SIGN) { 259 + parse_legacy_section_text(s) 260 + } else { 261 + Component::String(s.to_string()) 262 + } 263 + } 264 + 265 + fn expand_legacy(component: Component) -> Component { 266 + match component { 267 + Component::String(s) => legacy_or_plain(&s), 268 + Component::Array(items) => { 269 + Component::Array(items.into_iter().map(expand_legacy).collect()) 270 + } 271 + Component::Object(mut obj) => { 272 + if obj.translate.is_none() 273 + && obj.with_.is_empty() 274 + && let Some(text) = obj.text.clone() 275 + && text.contains('§') 276 + { 277 + let expanded = parse_legacy_section_text_with_color( 278 + &text, 279 + obj.color.clone(), 280 + ); 281 + return merge_expanded_with_extra(expanded, obj.extra); 282 + } 283 + obj.extra = obj.extra.into_iter().map(expand_legacy).collect(); 284 + obj.with_ = obj.with_.into_iter().map(expand_legacy).collect(); 285 + Component::Object(obj) 286 + } 287 + } 288 + } 289 + 290 + fn merge_expanded_with_extra( 291 + expanded: Component, 292 + extra: Vec<Component>, 293 + ) -> Component { 294 + if extra.is_empty() { 295 + return expanded; 296 + } 297 + match expanded { 298 + Component::Array(mut items) => { 299 + items.extend(extra.into_iter().map(expand_legacy)); 300 + Component::Array(items) 301 + } 302 + other => Component::Array( 303 + std::iter::once(other) 304 + .chain(extra.into_iter().map(expand_legacy)) 305 + .collect(), 306 + ), 307 + } 308 + } 309 + 310 + fn parse_legacy_section_text(s: &str) -> Component { 311 + parse_legacy_section_text_with_color(s, None) 312 + } 313 + 314 + fn parse_legacy_section_text_with_color( 315 + s: &str, 316 + initial_color: Option<String>, 317 + ) -> Component { 318 + let mut out: Vec<Component> = Vec::new(); 319 + let mut buf = String::new(); 320 + let mut color: Option<String> = initial_color; 321 + 322 + let mut chars = s.chars(); 323 + while let Some(c) = chars.next() { 324 + if c == SECTION_SIGN 325 + && let Some(code) = chars.next() 326 + { 327 + if !buf.is_empty() { 328 + out.push(Component::Object(ComponentObject { 329 + text: Some(std::mem::take(&mut buf)), 330 + color: color.clone(), 331 + extra: Vec::new(), 332 + translate: None, 333 + with_: Vec::new(), 334 + })); 335 + } 336 + 337 + match legacy_color_name(code) { 338 + Some(name) => color = Some(name), 339 + None => { 340 + if code == LEGACY_RESET_LOWER || code == LEGACY_RESET_UPPER 341 + { 342 + color = None; 343 + } 344 + } 345 + } 346 + continue; 347 + } 348 + buf.push(c); 349 + } 350 + 351 + if !buf.is_empty() { 352 + out.push(Component::Object(ComponentObject { 353 + text: Some(buf), 354 + color, 355 + extra: Vec::new(), 356 + translate: None, 357 + with_: Vec::new(), 358 + })); 359 + } 360 + 361 + if out.is_empty() { 362 + Component::String(String::new()) 363 + } else if out.len() == 1 { 364 + out.pop() 365 + .unwrap_or_else(|| Component::String(String::new())) 366 + } else { 367 + Component::Array(out) 368 + } 369 + } 370 + 371 + fn legacy_color_name(code: char) -> Option<String> { 372 + let name = match code { 373 + '0' => "black", 374 + '1' => "dark_blue", 375 + '2' => "dark_green", 376 + '3' => "dark_aqua", 377 + '4' => "dark_red", 378 + '5' => "dark_purple", 379 + '6' => "gold", 380 + '7' => "gray", 381 + '8' => "dark_gray", 382 + '9' => "blue", 383 + 'a' | 'A' => "green", 384 + 'b' | 'B' => "aqua", 385 + 'c' | 'C' => "red", 386 + 'd' | 'D' => "light_purple", 387 + 'e' | 'E' => "yellow", 388 + 'f' | 'F' => "white", 389 + _ => return None, 390 + }; 391 + Some(name.to_string()) 392 + } 393 + 394 + const SECTION_SIGN: char = '§'; 395 + const LEGACY_RESET_LOWER: char = 'r'; 396 + const LEGACY_RESET_UPPER: char = 'R'; 397 + 398 + fn push_text_with_color(out: &mut String, text: &str, color: Option<&str>) { 399 + let class_color = color 400 + .and_then(sanitize_color_class) 401 + .map(|c| format!("text-{}", c)); 402 + if let Some(class_color) = class_color { 403 + out.push_str("<span class=\""); 404 + out.push_str(&class_color); 405 + out.push_str("\">"); 406 + push_escaped_with_breaks(out, text); 407 + out.push_str("</span>"); 408 + } else { 409 + push_escaped_with_breaks(out, text); 410 + } 411 + } 412 + 413 + fn sanitize_color_class(color: &str) -> Option<&str> { 414 + if color.is_empty() { 415 + return None; 416 + } 417 + if color 418 + .chars() 419 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') 420 + { 421 + Some(color) 422 + } else { 423 + None 424 + } 425 + } 426 + 427 + fn push_escaped_with_breaks(out: &mut String, text: &str) { 428 + let mut first = true; 429 + for part in text.split('\n') { 430 + if !first { 431 + out.push_str("<br>"); 432 + } 433 + first = false; 434 + out.push_str(encode_text(part).as_ref()); 435 + } 436 + } 437 + 438 + impl<'de> Deserialize<'de> for Component { 439 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 440 + where 441 + D: Deserializer<'de>, 442 + { 443 + struct ComponentVisitor; 444 + 445 + impl<'de> Visitor<'de> for ComponentVisitor { 446 + type Value = Component; 447 + 448 + fn expecting( 449 + &self, 450 + f: &mut std::fmt::Formatter, 451 + ) -> std::fmt::Result { 452 + f.write_str("a Minecraft text component") 453 + } 454 + 455 + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 456 + where 457 + E: serde::de::Error, 458 + { 459 + Ok(parse_component_from_str(v)) 460 + } 461 + 462 + fn visit_string<E>(self, v: String) -> Result<Self::Value, E> 463 + where 464 + E: serde::de::Error, 465 + { 466 + Ok(parse_component_from_str(&v)) 467 + } 468 + 469 + fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error> 470 + where 471 + A: SeqAccess<'de>, 472 + { 473 + let items = Vec::<Component>::deserialize( 474 + SeqAccessDeserializer::new(seq), 475 + )?; 476 + Ok(expand_legacy(Component::Array(items))) 477 + } 478 + 479 + fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error> 480 + where 481 + A: MapAccess<'de>, 482 + { 483 + let obj = ComponentObject::deserialize( 484 + MapAccessDeserializer::new(map), 485 + )?; 486 + Ok(expand_legacy(Component::Object(obj))) 487 + } 488 + } 489 + 490 + deserializer.deserialize_any(ComponentVisitor) 491 + } 492 + } 493 + 494 + #[cfg(test)] 495 + mod tests { 496 + use super::*; 497 + 498 + #[test] 499 + fn normalize_plain_text() { 500 + let c = parse_component_from_str("hello world"); 501 + assert_eq!(normalize_page(&c), "hello world"); 502 + } 503 + 504 + #[test] 505 + fn normalize_json_object() { 506 + let c = parse_component_from_str(r#"{"text":"hello §aworld"}"#); 507 + assert_eq!(normalize_page(&c), "hello world"); 508 + } 509 + 510 + #[test] 511 + fn normalize_json_array() { 512 + let c = 513 + parse_component_from_str(r#"[{"text":"hi "},{"text":"there"}]"#); 514 + assert_eq!(normalize_page(&c), "hi there"); 515 + } 516 + 517 + #[test] 518 + fn normalize_stringified_json() { 519 + let c = parse_component_from_str(r#""{\"text\":\"hello\"}""#); 520 + assert_eq!(normalize_page(&c), "hello"); 521 + } 522 + 523 + #[test] 524 + fn legacy_section_colors() { 525 + let c = parse_component_from_str("hi §aworld"); 526 + let text = c.to_plain_text(); 527 + assert_eq!(text, "hi world"); 528 + match c { 529 + Component::Array(items) => { 530 + assert_eq!(items.len(), 2); 531 + } 532 + _ => panic!("expected array component"), 533 + } 534 + } 535 + 536 + #[test] 537 + fn legacy_text_prefix() { 538 + let c = parse_component_from_str("text\t\"hello §aworld\""); 539 + assert_eq!(normalize_page(&c), "hello world"); 540 + } 541 + 542 + #[test] 543 + fn scrub_unwanted_glyph_from_component_tree() { 544 + let mut c = parse_component_from_str( 545 + r#"[{"text":"hi there"},{"text":" and more"}]"#, 546 + ); 547 + scrub_component(&mut c); 548 + assert_eq!(c.to_plain_text(), "hi there and more"); 549 + } 550 + }
+57
src/main.rs
··· 1 + use std::{fs, io::Cursor}; 2 + 3 + use anyhow::Context; 4 + use clap::Parser as _; 5 + 6 + use crate::{ 7 + cli::Args, 8 + library::{Library, Realm}, 9 + web::start_webserver, 10 + }; 11 + 12 + pub mod cli; 13 + pub mod library; 14 + pub mod web; 15 + 16 + #[tokio::main] 17 + async fn main() -> anyhow::Result<()> { 18 + tracing_subscriber::fmt::init(); 19 + let cli_args = Args::parse(); 20 + let library = build_library(&cli_args).context("Building library")?; 21 + 22 + if cli_args.start_webserver { 23 + start_webserver(&cli_args, library) 24 + .await 25 + .context("Running webserver")?; 26 + } 27 + 28 + Ok(()) 29 + } 30 + 31 + fn build_library(args: &Args) -> anyhow::Result<Library> { 32 + let realm_path = &args.realm_path; 33 + 34 + if !fs::exists(realm_path) 35 + .context("Checking if supplied realm path exists")? 36 + { 37 + return Err(anyhow::anyhow!("File {:?} not found", realm_path)); 38 + } 39 + 40 + let bytes = fs::read(realm_path).context("Reading realm")?; 41 + let mut cursor: Cursor<&[u8]> = Cursor::new(&bytes); 42 + let realm = crab_nbt::serde::de::from_cursor::<Realm>(&mut cursor) 43 + .context("Reading realm NBT")?; 44 + 45 + let library = Library::new( 46 + realm, 47 + args.content_threshold, 48 + args.author_threshold, 49 + args.title_threshold, 50 + args.warn_duplicates, 51 + args.warn_empty, 52 + args.filter_empty_books, 53 + ) 54 + .context("Constructing library")?; 55 + 56 + Ok(library) 57 + }
+65
src/web.rs
··· 1 + use std::{ 2 + fmt::Display, 3 + sync::{Arc, Mutex}, 4 + }; 5 + 6 + use anyhow::Context as _; 7 + use axum::{Extension, Router, routing::get}; 8 + use tokio::net::TcpListener; 9 + use tower_http::compression::CompressionLayer; 10 + 11 + use crate::{cli::Args, library::Library}; 12 + 13 + pub mod api; 14 + pub mod assets; 15 + pub mod pages; 16 + 17 + #[derive(Debug, Clone, Copy, clap::ValueEnum)] 18 + pub enum TextureKind { 19 + /// <1.14 textures. 20 + Legacy, 21 + /// >=1.14 textures. 22 + Modern, 23 + } 24 + 25 + impl Display for TextureKind { 26 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 27 + match self { 28 + TextureKind::Legacy => write!(f, "legacy"), 29 + TextureKind::Modern => write!(f, "modern"), 30 + } 31 + } 32 + } 33 + 34 + pub async fn start_webserver( 35 + args: &Args, 36 + library: Library, 37 + ) -> anyhow::Result<()> { 38 + let library = Arc::new(Mutex::new(library)); 39 + let app = Router::new() 40 + .route("/", get(pages::index)) 41 + .route("/book/{hash}", get(pages::book)) 42 + .route("/random", get(pages::random_book)) 43 + .nest("/api", api::router(library.clone())) 44 + .nest("/assets", assets::router()) 45 + .layer(CompressionLayer::new()) 46 + .layer(Extension(library)) 47 + .layer(Extension(args.ui_textures)); 48 + 49 + let address = args 50 + .webserver_host_address 51 + .parse::<std::net::SocketAddr>() 52 + .context("Converting addr arg to socketaddr")?; 53 + 54 + let listener = TcpListener::bind(address) 55 + .await 56 + .context("Binding listener")?; 57 + 58 + tracing::info!("Binding webserver to {0}.", address); 59 + 60 + axum::serve(listener, app) 61 + .await 62 + .context("Serving webserver")?; 63 + 64 + Ok(()) 65 + }
+361
src/web/api.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + 3 + use axum::{ 4 + Json, Router, 5 + extract::{Path, Query, State}, 6 + http::StatusCode, 7 + response::{IntoResponse, Response}, 8 + routing::get, 9 + }; 10 + use serde::{Deserialize, Serialize}; 11 + 12 + use crate::library::{self, Library, item::WrittenBookTag, text::Component}; 13 + 14 + #[derive(Clone)] 15 + pub struct AppState { 16 + library: Arc<Mutex<Library>>, 17 + } 18 + 19 + pub fn router(library: Arc<Mutex<Library>>) -> Router { 20 + let state = AppState { library }; 21 + Router::new() 22 + .route("/", get(root)) 23 + .route("/health", get(health)) 24 + .route("/books", get(list_books)) 25 + .route("/books/by-author", get(books_by_author)) 26 + .route("/books/by-category", get(books_by_category)) 27 + .route("/books/by-hash/{hash}", get(book_by_hash)) 28 + .route("/categories", get(list_categories)) 29 + .route("/search", get(search_all)) 30 + .route("/search/title", get(search_title)) 31 + .route("/search/author", get(search_author)) 32 + .route("/search/contents", get(search_contents)) 33 + .with_state(state) 34 + } 35 + 36 + #[derive(serde::Serialize)] 37 + struct RootResponse { 38 + message: &'static str, 39 + endpoints: [&'static str; 10], 40 + } 41 + 42 + async fn root() -> axum::Json<RootResponse> { 43 + axum::Json(RootResponse { 44 + message: "Library API", 45 + endpoints: [ 46 + "/api/health", 47 + "/api/books?limit=25&offset=0", 48 + "/api/books/by-author?author=Name&limit=25&offset=0", 49 + "/api/books/by-category?category=Category&limit=25&offset=0", 50 + "/api/books/by-hash/:hash", 51 + "/api/categories", 52 + "/api/search?query=term&limit=25&offset=0", 53 + "/api/search/title?query=term&limit=25&offset=0", 54 + "/api/search/author?query=term&limit=25&offset=0", 55 + "/api/search/contents?query=term&limit=25&offset=0", 56 + ], 57 + }) 58 + } 59 + 60 + #[derive(Serialize)] 61 + struct HealthResponse { 62 + status: &'static str, 63 + books: usize, 64 + } 65 + 66 + async fn health(State(state): State<AppState>) -> Json<HealthResponse> { 67 + let library = state.library.lock().expect("library mutex poisoned"); 68 + Json(HealthResponse { 69 + status: "ok", 70 + books: library.book_count(), 71 + }) 72 + } 73 + 74 + #[derive(Deserialize)] 75 + struct ListParams { 76 + limit: Option<usize>, 77 + offset: Option<usize>, 78 + } 79 + 80 + #[derive(Serialize)] 81 + struct ListResponse { 82 + total: usize, 83 + offset: usize, 84 + limit: usize, 85 + items: Vec<BookDetail>, 86 + } 87 + 88 + async fn list_books( 89 + State(state): State<AppState>, 90 + Query(params): Query<ListParams>, 91 + ) -> Json<ListResponse> { 92 + let library = state.library.lock().expect("library mutex poisoned"); 93 + let total = library.book_count(); 94 + let limit = clamp_limit(params.limit); 95 + let offset = params.offset.unwrap_or(0).min(total); 96 + 97 + let items = library 98 + .all_books() 99 + .skip(offset) 100 + .take(limit) 101 + .map(|book| book_to_detail(book, &library)) 102 + .collect(); 103 + 104 + Json(ListResponse { 105 + total, 106 + offset, 107 + limit, 108 + items, 109 + }) 110 + } 111 + 112 + #[derive(Deserialize)] 113 + struct AuthorQuery { 114 + author: String, 115 + limit: Option<usize>, 116 + offset: Option<usize>, 117 + } 118 + 119 + async fn books_by_author( 120 + State(state): State<AppState>, 121 + Query(query): Query<AuthorQuery>, 122 + ) -> Result<Json<Vec<BookDetail>>, ApiError> { 123 + let library = state.library.lock().expect("library mutex poisoned"); 124 + let author = query.author.trim(); 125 + if author.is_empty() { 126 + return Err(ApiError::bad_request("author is required")); 127 + } 128 + 129 + let limit = clamp_limit(query.limit); 130 + let offset = query.offset.unwrap_or(0); 131 + 132 + let items = library 133 + .books_by_author(author) 134 + .skip(offset) 135 + .take(limit) 136 + .map(|book| book_to_detail(book, &library)) 137 + .collect(); 138 + 139 + Ok(Json(items)) 140 + } 141 + 142 + #[derive(Deserialize)] 143 + struct CategoryQuery { 144 + category: String, 145 + limit: Option<usize>, 146 + offset: Option<usize>, 147 + } 148 + 149 + async fn books_by_category( 150 + State(state): State<AppState>, 151 + Query(query): Query<CategoryQuery>, 152 + ) -> Result<Json<Vec<BookDetail>>, ApiError> { 153 + let library = state.library.lock().expect("library mutex poisoned"); 154 + let category = query.category.trim(); 155 + if category.is_empty() { 156 + return Err(ApiError::bad_request("category is required")); 157 + } 158 + 159 + let limit = clamp_limit(query.limit); 160 + let offset = query.offset.unwrap_or(0); 161 + 162 + let items = library 163 + .books_in_category(category) 164 + .skip(offset) 165 + .take(limit) 166 + .map(|book| book_to_detail(book, &library)) 167 + .collect(); 168 + 169 + Ok(Json(items)) 170 + } 171 + 172 + async fn book_by_hash( 173 + State(state): State<AppState>, 174 + Path(hash): Path<String>, 175 + ) -> Result<Json<BookDetail>, ApiError> { 176 + let library = state.library.lock().expect("library mutex poisoned"); 177 + let hash = parse_hash(&hash)?; 178 + let Some(book) = library.book_by_hash(hash) else { 179 + return Err(ApiError::not_found("book not found")); 180 + }; 181 + 182 + Ok(Json(book_to_detail(book, &library))) 183 + } 184 + 185 + #[derive(Serialize)] 186 + struct CategorySummary { 187 + name: String, 188 + count: usize, 189 + } 190 + 191 + async fn list_categories( 192 + State(state): State<AppState>, 193 + ) -> Json<Vec<CategorySummary>> { 194 + let library = state.library.lock().expect("library mutex poisoned"); 195 + let mut categories: Vec<CategorySummary> = library 196 + .categories() 197 + .into_iter() 198 + .map(|(name, count)| CategorySummary { 199 + name: name.to_string(), 200 + count, 201 + }) 202 + .collect(); 203 + categories.sort_by(|a, b| a.name.cmp(&b.name)); 204 + Json(categories) 205 + } 206 + 207 + #[derive(Deserialize)] 208 + struct SearchQuery { 209 + query: String, 210 + limit: Option<usize>, 211 + offset: Option<usize>, 212 + } 213 + 214 + #[derive(Serialize)] 215 + struct SearchResult { 216 + score: f64, 217 + book: BookDetail, 218 + } 219 + 220 + async fn search_all( 221 + State(state): State<AppState>, 222 + Query(query): Query<SearchQuery>, 223 + ) -> Result<Json<Vec<SearchResult>>, ApiError> { 224 + let library = state.library.lock().expect("library mutex poisoned"); 225 + run_search(&library, query, Library::fuzzy) 226 + } 227 + 228 + async fn search_title( 229 + State(state): State<AppState>, 230 + Query(query): Query<SearchQuery>, 231 + ) -> Result<Json<Vec<SearchResult>>, ApiError> { 232 + let library = state.library.lock().expect("library mutex poisoned"); 233 + run_search(&library, query, Library::fuzzy_title) 234 + } 235 + 236 + async fn search_author( 237 + State(state): State<AppState>, 238 + Query(query): Query<SearchQuery>, 239 + ) -> Result<Json<Vec<SearchResult>>, ApiError> { 240 + let library = state.library.lock().expect("library mutex poisoned"); 241 + run_search(&library, query, Library::fuzzy_author) 242 + } 243 + 244 + async fn search_contents( 245 + State(state): State<AppState>, 246 + Query(query): Query<SearchQuery>, 247 + ) -> Result<Json<Vec<SearchResult>>, ApiError> { 248 + let library = state.library.lock().expect("library mutex poisoned"); 249 + run_search(&library, query, Library::fuzzy_contents) 250 + } 251 + 252 + fn run_search( 253 + library: &Library, 254 + query: SearchQuery, 255 + search_fn: for<'a> fn( 256 + &'a Library, 257 + &str, 258 + usize, 259 + ) -> Vec<(&'a WrittenBookTag, f64)>, 260 + ) -> Result<Json<Vec<SearchResult>>, ApiError> { 261 + let query_str = query.query.trim(); 262 + if query_str.is_empty() { 263 + return Err(ApiError::bad_request("query is required")); 264 + } 265 + let limit = clamp_limit(query.limit); 266 + let offset = query.offset.unwrap_or(0); 267 + let fetch = clamp_limit(Some(limit.saturating_add(offset))); 268 + 269 + let items = search_fn(library, query_str, fetch) 270 + .into_iter() 271 + .skip(offset) 272 + .take(limit) 273 + .map(|(book, score)| SearchResult { 274 + score, 275 + book: book_to_detail(book, library), 276 + }) 277 + .collect(); 278 + 279 + Ok(Json(items)) 280 + } 281 + 282 + #[derive(Serialize)] 283 + struct BookDetail { 284 + title: String, 285 + author: String, 286 + pages: Vec<Component>, 287 + hash: String, 288 + location: Option<String>, 289 + category: Option<String>, 290 + } 291 + 292 + fn book_to_detail(book: &WrittenBookTag, library: &Library) -> BookDetail { 293 + let hash = book.hash(); 294 + let hash_hex = hex::encode(hash); 295 + let location = library.location_for_hash(&hash).map(|s| s.to_string()); 296 + let category = location 297 + .as_deref() 298 + .and_then(library::category_from_location) 299 + .map(|s| s.to_string()); 300 + 301 + BookDetail { 302 + title: book.title.clone(), 303 + author: book.author.clone(), 304 + pages: book.pages.clone(), 305 + hash: hash_hex, 306 + location, 307 + category, 308 + } 309 + } 310 + 311 + fn clamp_limit(limit: Option<usize>) -> usize { 312 + let limit = limit.unwrap_or(25); 313 + limit.clamp(1, 50) 314 + } 315 + 316 + fn parse_hash(hex_str: &str) -> Result<[u8; 20], ApiError> { 317 + let raw = hex::decode(hex_str) 318 + .map_err(|_| ApiError::bad_request("invalid hash"))?; 319 + if raw.len() != 20 { 320 + return Err(ApiError::bad_request("invalid hash length")); 321 + } 322 + let mut out = [0u8; 20]; 323 + out.copy_from_slice(&raw); 324 + Ok(out) 325 + } 326 + 327 + #[derive(Debug, Serialize)] 328 + struct ErrorResponse { 329 + error: String, 330 + } 331 + 332 + #[derive(Debug)] 333 + struct ApiError { 334 + status: StatusCode, 335 + message: String, 336 + } 337 + 338 + impl ApiError { 339 + fn bad_request(message: &str) -> Self { 340 + Self { 341 + status: StatusCode::BAD_REQUEST, 342 + message: message.to_string(), 343 + } 344 + } 345 + 346 + fn not_found(message: &str) -> Self { 347 + Self { 348 + status: StatusCode::NOT_FOUND, 349 + message: message.to_string(), 350 + } 351 + } 352 + } 353 + 354 + impl IntoResponse for ApiError { 355 + fn into_response(self) -> Response { 356 + let body = Json(ErrorResponse { 357 + error: self.message, 358 + }); 359 + (self.status, body).into_response() 360 + } 361 + }
+133
src/web/assets.rs
··· 1 + use axum::{ 2 + Router, body::Bytes, http::header, response::IntoResponse, routing::get, 3 + }; 4 + 5 + const LEGACY_WRITTEN_BOOK: &[u8; 186] = 6 + include_bytes!("assets/image/legacy_written_book.webp"); 7 + const MODERN_WRITTEN_BOOK: &[u8; 152] = 8 + include_bytes!("assets/image/modern_written_book.webp"); 9 + const ENCHANTMENT_GLINT: &[u8; 294] = include_bytes!("assets/image/glint.webp"); 10 + const BOOK_BACKGROUND: &[u8; 3928] = 11 + include_bytes!("assets/image/book_background.webp"); 12 + 13 + const FONT_REGULAR: &[u8; 41452] = include_bytes!("assets/font/regular.woff2"); 14 + const FONT_BOLD: &[u8; 39952] = include_bytes!("assets/font/bold.woff2"); 15 + const FONT_ITALIC: &[u8; 45500] = include_bytes!("assets/font/italic.woff2"); 16 + const FONT_BOLD_ITALIC: &[u8; 43716] = 17 + include_bytes!("assets/font/bold_italic.woff2"); 18 + 19 + const CSS_STYLES: &str = include_str!("assets/stylesheet.css"); 20 + 21 + const WEBP_MIME_TYPE: &str = "image/webp"; 22 + const WOFF2_MIME_TYPE: &str = "font/woff2"; 23 + const CSS_MIME_TYPE: &str = "text/css"; 24 + 25 + #[cfg(not(debug_assertions))] 26 + const CACHE_CONTROL_HEADER: &str = "public, max-age=31536000, immutable"; 27 + 28 + #[cfg(debug_assertions)] 29 + const CACHE_CONTROL_HEADER: &str = "public, no-cache, no-store"; 30 + 31 + pub fn router() -> Router { 32 + Router::new() 33 + .route("/image/legacy/written_book.webp", get(legacy_written_book)) 34 + .route("/image/modern/written_book.webp", get(modern_written_book)) 35 + .route("/image/glint.webp", get(enchantment_glint)) 36 + .route("/image/book_background.webp", get(book_background)) 37 + .route("/font/regular.woff2", get(regular_font)) 38 + .route("/font/bold.woff2", get(bold_font)) 39 + .route("/font/italic.woff2", get(italic_font)) 40 + .route("/font/bold_italic.woff2", get(bold_italic_font)) 41 + .route("/stylesheet.css", get(stylesheet)) 42 + } 43 + 44 + async fn legacy_written_book() -> impl IntoResponse { 45 + ( 46 + [ 47 + (header::CONTENT_TYPE, WEBP_MIME_TYPE), 48 + #[cfg(not(debug_assertions))] 49 + (header::CACHE_CONTROL, CACHE_CONTROL_HEADER), 50 + ], 51 + Bytes::from_static(LEGACY_WRITTEN_BOOK), 52 + ) 53 + } 54 + 55 + async fn modern_written_book() -> impl IntoResponse { 56 + ( 57 + [ 58 + (header::CONTENT_TYPE, WEBP_MIME_TYPE), 59 + (header::CACHE_CONTROL, CACHE_CONTROL_HEADER), 60 + ], 61 + Bytes::from_static(MODERN_WRITTEN_BOOK), 62 + ) 63 + } 64 + 65 + async fn book_background() -> impl IntoResponse { 66 + ( 67 + [ 68 + (header::CONTENT_TYPE, WEBP_MIME_TYPE), 69 + (header::CACHE_CONTROL, CACHE_CONTROL_HEADER), 70 + ], 71 + Bytes::from_static(BOOK_BACKGROUND), 72 + ) 73 + } 74 + 75 + async fn enchantment_glint() -> impl IntoResponse { 76 + ( 77 + [ 78 + (header::CONTENT_TYPE, WEBP_MIME_TYPE), 79 + (header::CACHE_CONTROL, CACHE_CONTROL_HEADER), 80 + ], 81 + Bytes::from_static(ENCHANTMENT_GLINT), 82 + ) 83 + } 84 + 85 + async fn stylesheet() -> impl IntoResponse { 86 + ( 87 + [ 88 + (header::CONTENT_TYPE, CSS_MIME_TYPE), 89 + (header::CACHE_CONTROL, CACHE_CONTROL_HEADER), 90 + ], 91 + CSS_STYLES, 92 + ) 93 + } 94 + 95 + async fn regular_font() -> impl IntoResponse { 96 + ( 97 + [ 98 + (header::CONTENT_TYPE, WOFF2_MIME_TYPE), 99 + (header::CACHE_CONTROL, CACHE_CONTROL_HEADER), 100 + ], 101 + Bytes::from_static(FONT_REGULAR), 102 + ) 103 + } 104 + 105 + async fn bold_font() -> impl IntoResponse { 106 + ( 107 + [ 108 + (header::CONTENT_TYPE, WOFF2_MIME_TYPE), 109 + (header::CACHE_CONTROL, CACHE_CONTROL_HEADER), 110 + ], 111 + Bytes::from_static(FONT_BOLD), 112 + ) 113 + } 114 + 115 + async fn italic_font() -> impl IntoResponse { 116 + ( 117 + [ 118 + (header::CONTENT_TYPE, WOFF2_MIME_TYPE), 119 + (header::CACHE_CONTROL, CACHE_CONTROL_HEADER), 120 + ], 121 + Bytes::from_static(FONT_ITALIC), 122 + ) 123 + } 124 + 125 + async fn bold_italic_font() -> impl IntoResponse { 126 + ( 127 + [ 128 + (header::CONTENT_TYPE, WOFF2_MIME_TYPE), 129 + (header::CACHE_CONTROL, CACHE_CONTROL_HEADER), 130 + ], 131 + Bytes::from_static(FONT_BOLD_ITALIC), 132 + ) 133 + }
src/web/assets/font/bold.woff2

This is a binary file and will not be displayed.

src/web/assets/font/bold_italic.woff2

This is a binary file and will not be displayed.

src/web/assets/font/italic.woff2

This is a binary file and will not be displayed.

src/web/assets/font/regular.woff2

This is a binary file and will not be displayed.

src/web/assets/image/book_background.webp

This is a binary file and will not be displayed.

src/web/assets/image/glint.webp

This is a binary file and will not be displayed.

src/web/assets/image/legacy_written_book.webp

This is a binary file and will not be displayed.

src/web/assets/image/modern_written_book.webp

This is a binary file and will not be displayed.

+772
src/web/assets/stylesheet.css
··· 1 + @font-face { 2 + font-family: "Minecraft"; 3 + src: url("/assets/font/regular.woff2") format("woff2"); 4 + font-weight: 400; 5 + font-style: normal; 6 + font-display: swap; 7 + } 8 + 9 + @font-face { 10 + font-family: "Minecraft"; 11 + src: url("/assets/font/bold.woff2") format("woff2"); 12 + font-weight: 700; 13 + font-style: normal; 14 + font-display: swap; 15 + } 16 + 17 + @font-face { 18 + font-family: "Minecraft"; 19 + src: url("/assets/font/italic.woff2") format("woff2"); 20 + font-weight: 400; 21 + font-style: italic; 22 + font-display: swap; 23 + } 24 + 25 + @font-face { 26 + font-family: "Minecraft"; 27 + src: url("/assets/font/bold_italic.woff2") format("woff2"); 28 + font-weight: 700; 29 + font-style: italic; 30 + font-display: swap; 31 + } 32 + 33 + .font-minecraft { 34 + font-family: "Minecraft", system-ui, -apple-system, "Segoe UI", sans-serif; 35 + font-synthesis: none; 36 + } 37 + 38 + .text-black { 39 + color: #000000; 40 + } 41 + 42 + .text-dark_blue { 43 + color: #0000AA; 44 + } 45 + 46 + .text-dark_green { 47 + color: #00AA00; 48 + } 49 + 50 + .text-dark_aqua { 51 + color: #00AAAA; 52 + } 53 + 54 + .text-dark_red { 55 + color: #AA0000; 56 + } 57 + 58 + .text-dark_purple { 59 + color: #AA00AA; 60 + } 61 + 62 + .text-gold { 63 + color: #FFAA00; 64 + } 65 + 66 + .text-gray { 67 + color: #AAAAAA; 68 + } 69 + 70 + .text-dark_gray { 71 + color: #555555; 72 + } 73 + 74 + .text-blue { 75 + color: #5555FF; 76 + } 77 + 78 + .text-green { 79 + color: #55FF55; 80 + } 81 + 82 + .text-aqua { 83 + color: #55FFFF; 84 + } 85 + 86 + .text-red { 87 + color: #FF5555; 88 + } 89 + 90 + .text-light_purple { 91 + color: #FF55FF; 92 + } 93 + 94 + .text-yellow { 95 + color: #FFFF55; 96 + } 97 + 98 + .text-white { 99 + color: #FFFFFF; 100 + } 101 + 102 + :root { 103 + color-scheme: light; 104 + --ink: #101010; 105 + --ink-muted: #2f2f2f; 106 + --surface: #c6c6c6; 107 + --surface-strong: #b7b7b7; 108 + --surface-weak: #d7d7d7; 109 + --accent: #4f9a3a; 110 + --accent-strong: #336f26; 111 + --border: #3b3b3b; 112 + --shadow: none; 113 + --book-sprite-width: clamp(12.5rem, 24vw, 14.5rem); 114 + --book-sprite-height: calc(var(--book-sprite-width) * 180 / 146); 115 + --book-header-top: calc(var(--book-sprite-width) * 12 / 146); 116 + --book-header-font-size: calc(var(--book-sprite-width) * 12 / 146); 117 + --book-text-width: calc(var(--book-sprite-width) * 118 / 146); 118 + --book-text-height: calc(var(--book-sprite-width) * 124 / 146); 119 + --book-text-top: calc(var(--book-sprite-width) * 24 / 146); 120 + --book-grid-gap: clamp(1rem, 2vw, 1.5rem); 121 + --book-page-font-size: calc(var(--book-sprite-width) * 12 / 146); 122 + --book-page-line-height: 0.8; 123 + } 124 + 125 + * { 126 + box-sizing: border-box; 127 + } 128 + 129 + congress html { 130 + font-size: clamp(0.875rem, 0.25vw + 0.8rem, 1rem); 131 + } 132 + 133 + body { 134 + margin: 0; 135 + font-family: inherit; 136 + color: var(--ink); 137 + background: 138 + linear-gradient(45deg, #7f7f7f 25%, #777777 25%, #777777 50%, #7f7f7f 50%, #7f7f7f 75%, #777777 75%, #777777 100%); 139 + background-size: 1rem 1rem; 140 + } 141 + 142 + a { 143 + color: var(--accent-strong); 144 + text-decoration: none; 145 + } 146 + 147 + a:hover { 148 + text-decoration: underline; 149 + } 150 + 151 + .page { 152 + max-width: 71.875rem; 153 + margin: 0 auto; 154 + padding: 2rem 1.5rem 3rem; 155 + min-height: 100vh; 156 + min-height: 100dvh; 157 + display: flex; 158 + flex-direction: column; 159 + } 160 + 161 + .hero { 162 + background: var(--surface); 163 + border: 0.0625rem solid var(--border); 164 + border-radius: 1.125rem; 165 + padding: 1.5rem; 166 + box-shadow: var(--shadow); 167 + } 168 + 169 + .brand { 170 + display: flex; 171 + align-items: center; 172 + gap: 1.125rem; 173 + } 174 + 175 + .book-badge { 176 + width: 3.5rem; 177 + height: 3.5rem; 178 + aspect-ratio: 1 / 1; 179 + flex: 0 0 auto; 180 + border-radius: 0.875rem; 181 + background: var(--surface-strong); 182 + display: inline-flex; 183 + align-items: center; 184 + justify-content: center; 185 + border: 0.0625rem solid var(--border); 186 + } 187 + 188 + .book-badge img { 189 + width: 2.5rem; 190 + height: 2.5rem; 191 + object-fit: contain; 192 + congress 193 + } 194 + 195 + .eyebrow { 196 + text-transform: uppercase; 197 + font-size: 0.75rem; 198 + letter-spacing: 0.12em; 199 + color: var(--ink-muted); 200 + margin: 0 0 0.375rem; 201 + } 202 + 203 + .hero h1 { 204 + margin: 0; 205 + font-size: 2rem; 206 + } 207 + 208 + .subtle { 209 + color: var(--ink-muted); 210 + margin: 0.375rem 0 0; 211 + } 212 + 213 + .search { 214 + display: grid; 215 + grid-template-columns: repeat(auto-fit, minmax(11.25rem, 1fr)); 216 + gap: 0.75rem; 217 + margin-top: 1.25rem; 218 + align-items: end; 219 + } 220 + 221 + .field { 222 + display: flex; 223 + flex-direction: column; 224 + gap: 0.375rem; 225 + font-size: 0.8125rem; 226 + color: var(--ink-muted); 227 + } 228 + 229 + input[type="search"], 230 + select { 231 + border-radius: 0.625rem; 232 + border: 0.0625rem solid var(--border); 233 + padding: 0.625rem 0.75rem; 234 + font-family: inherit; 235 + font-size: 0.875rem; 236 + background: var(--surface-weak); 237 + color: var(--ink); 238 + } 239 + 240 + input[type="search"]:focus, 241 + select:focus { 242 + outline: 0.125rem solid rgba(179, 106, 46, 0.35); 243 + border-color: var(--accent); 244 + } 245 + 246 + .actions { 247 + display: flex; 248 + gap: 0.75rem; 249 + align-items: center; 250 + grid-column: 1 / -1; 251 + } 252 + 253 + .actions .surprise-button { 254 + margin-left: auto; 255 + } 256 + 257 + button, 258 + .button { 259 + border: none; 260 + border-radius: 0.625rem; 261 + padding: 0.625rem 1.125rem; 262 + background: var(--accent); 263 + color: #fff; 264 + font-family: inherit; 265 + font-size: 0.875rem; 266 + cursor: pointer; 267 + } 268 + 269 + .button.ghost, 270 + .link-reset { 271 + background: transparent; 272 + color: var(--accent-strong); 273 + border: 0.0625rem solid var(--border); 274 + padding: 0.5625rem 1rem; 275 + border-radius: 0.625rem; 276 + } 277 + 278 + .active-filters { 279 + display: flex; 280 + gap: 0.625rem; 281 + margin-top: 1rem; 282 + flex-wrap: wrap; 283 + } 284 + 285 + .chip { 286 + background: rgba(179, 106, 46, 0.12); 287 + color: var(--accent-strong); 288 + padding: 0.375rem 0.625rem; 289 + border-radius: 999rem; 290 + font-size: 0.75rem; 291 + } 292 + 293 + .layout { 294 + display: grid; 295 + grid-template-columns: 16.25rem 1fr; 296 + gap: 1.5rem; 297 + margin-top: 1.5rem; 298 + } 299 + 300 + .sidebar .panel { 301 + background: var(--surface-weak); 302 + border: 0.0625rem solid var(--border); 303 + border-radius: 1rem; 304 + padding: 1.125rem; 305 + } 306 + 307 + .sidebar h2 { 308 + margin: 0 0 0.75rem; 309 + font-size: 1.125rem; 310 + } 311 + 312 + .all-link { 313 + display: inline-block; 314 + margin-bottom: 0.75rem; 315 + font-size: 0.8125rem; 316 + } 317 + 318 + .category-list { 319 + list-style: none; 320 + padding: 0; 321 + margin: 0; 322 + display: grid; 323 + gap: 0.5rem; 324 + } 325 + 326 + .category-link { 327 + display: flex; 328 + justify-content: space-between; 329 + padding: 0.5rem 0.625rem; 330 + border-radius: 0.625rem; 331 + border: 0.0625rem solid transparent; 332 + color: var(--ink); 333 + background: var(--surface); 334 + } 335 + 336 + .category-link.active { 337 + border-color: var(--accent); 338 + background: rgba(179, 106, 46, 0.12); 339 + } 340 + 341 + .category-link .count { 342 + color: var(--ink-muted); 343 + font-size: 0.75rem; 344 + } 345 + 346 + .results-header { 347 + display: flex; 348 + align-items: flex-end; 349 + justify-content: space-between; 350 + gap: 1.25rem; 351 + margin-bottom: 1rem; 352 + } 353 + 354 + .results h2 { 355 + margin: 0; 356 + font-size: 1.375rem; 357 + } 358 + 359 + .card-grid { 360 + display: grid; 361 + gap: 1.125rem; 362 + } 363 + 364 + .icon-grid { 365 + grid-template-columns: repeat(auto-fit, minmax(8.75rem, 1fr)); 366 + } 367 + 368 + .book-tile { 369 + text-align: center; 370 + padding: 0.75rem 0.625rem; 371 + border-radius: 0.875rem; 372 + border: 0.0625rem solid transparent; 373 + transition: border 0.15s ease, background 0.15s ease; 374 + } 375 + 376 + .book-tile:hover { 377 + border-color: var(--border); 378 + background: rgba(255, 255, 255, 0.65); 379 + } 380 + 381 + .book-tile h3 { 382 + margin: 0.625rem 0 0.25rem; 383 + font-size: 0.875rem; 384 + } 385 + 386 + .book-icon { 387 + width: 4.5rem; 388 + height: 4.5rem; 389 + border-radius: 1rem; 390 + border: 0.0625rem solid var(--border); 391 + background: var(--surface); 392 + display: inline-flex; 393 + align-items: center; 394 + justify-content: center; 395 + box-shadow: 0 0.625rem 1.125rem rgba(20, 14, 9, 0.12); 396 + transition: transform 0.15s ease, box-shadow 0.15s ease; 397 + } 398 + 399 + .book-icon:hover { 400 + transform: translateY(-0.125rem); 401 + box-shadow: 0 0.875rem 1.625rem rgba(20, 14, 9, 0.16); 402 + } 403 + 404 + .book-icon img { 405 + width: 2.5rem; 406 + height: 2.5rem; 407 + } 408 + 409 + .card-info { 410 + display: grid; 411 + gap: 0.375rem; 412 + } 413 + 414 + .meta { 415 + margin: 0; 416 + color: var(--ink-muted); 417 + font-size: 0.75rem; 418 + } 419 + 420 + .detail-link { 421 + font-size: 0.8125rem; 422 + border: 0.0625rem solid var(--border); 423 + border-radius: 999rem; 424 + padding: 0.375rem 0.75rem; 425 + } 426 + 427 + .meta-row { 428 + display: flex; 429 + flex-wrap: wrap; 430 + gap: 0.5rem; 431 + margin-top: 0.625rem; 432 + align-items: center; 433 + } 434 + 435 + .tag { 436 + display: inline-flex; 437 + align-items: center; 438 + border-radius: 999rem; 439 + background: var(--surface); 440 + border: 0.0625rem solid var(--border); 441 + padding: 0 0.625rem; 442 + font-size: 0.75rem; 443 + line-height: 1; 444 + height: 1.5rem; 445 + font-weight: 400; 446 + color: var(--ink); 447 + text-decoration: none; 448 + } 449 + 450 + .tag.subtle { 451 + margin: 0; 452 + color: var(--ink-muted); 453 + } 454 + 455 + .tag-link { 456 + display: inline-flex; 457 + text-decoration: none; 458 + } 459 + 460 + .preview { 461 + margin-top: 0.875rem; 462 + display: grid; 463 + gap: 0.625rem; 464 + } 465 + 466 + .page-preview { 467 + background: var(--surface); 468 + border-radius: 0.75rem; 469 + padding: 0.625rem 0.75rem; 470 + border: 0.0625rem solid var(--border); 471 + } 472 + 473 + .page-label { 474 + margin: 0 0 0.375rem; 475 + font-size: 0.75rem; 476 + color: var(--ink-muted); 477 + text-transform: uppercase; 478 + letter-spacing: 0.08em; 479 + } 480 + 481 + .empty-state { 482 + border: 0.0625rem dashed var(--border); 483 + padding: 1.25rem; 484 + border-radius: 0.875rem; 485 + background: rgba(255, 255, 255, 0.6); 486 + margin-bottom: 1rem; 487 + } 488 + 489 + .pager { 490 + display: flex; 491 + gap: 0.75rem; 492 + margin-top: 1.125rem; 493 + } 494 + 495 + .pager-top { 496 + margin-top: 0; 497 + margin-bottom: 1rem; 498 + } 499 + 500 + .pager-spacer { 501 + flex: 1 1 auto; 502 + } 503 + 504 + .footer { 505 + margin-top: auto; 506 + padding-top: 1.75rem; 507 + text-align: center; 508 + color: var(--ink-muted); 509 + } 510 + 511 + .detail { 512 + max-width: 71.875rem; 513 + } 514 + 515 + .detail-hero { 516 + display: flex; 517 + align-items: center; 518 + justify-content: space-between; 519 + gap: 1.125rem; 520 + } 521 + 522 + .detail-hero .brand { 523 + flex: 1 1 auto; 524 + min-width: 0; 525 + } 526 + 527 + .detail-actions { 528 + display: flex; 529 + justify-content: flex-end; 530 + } 531 + 532 + .detail-body { 533 + margin-top: 1.5rem; 534 + width: 100%; 535 + max-width: calc((var(--book-sprite-width) * 3) + (var(--book-grid-gap) * 2)); 536 + margin-inline: auto; 537 + } 538 + 539 + .pages { 540 + display: grid; 541 + grid-template-columns: repeat(auto-fit, var(--book-sprite-width)); 542 + gap: var(--book-grid-gap); 543 + justify-content: center; 544 + } 545 + 546 + .book-page { 547 + display: grid; 548 + justify-items: center; 549 + gap: 0; 550 + } 551 + 552 + .book-page-sprite { 553 + width: var(--book-sprite-width); 554 + height: var(--book-sprite-height); 555 + background-image: url("/assets/image/book_background.webp"); 556 + background-size: 100% 100%; 557 + background-repeat: no-repeat; 558 + image-rendering: pixelated; 559 + position: relative; 560 + filter: none; 561 + } 562 + 563 + .book-page-text { 564 + position: absolute; 565 + top: var(--book-text-top); 566 + left: 50%; 567 + transform: translateX(-50%); 568 + width: var(--book-text-width); 569 + max-width: var(--book-text-width); 570 + height: var(--book-text-height); 571 + overflow: hidden; 572 + font-size: var(--book-page-font-size); 573 + line-height: var(--book-page-line-height); 574 + margin-left: 0.125rem; 575 + white-space: pre-wrap; 576 + overflow-wrap: anywhere; 577 + word-break: break-word; 578 + color: #000000; 579 + letter-spacing: 0; 580 + font-kerning: none; 581 + font-variant-ligatures: none; 582 + text-rendering: optimizeSpeed; 583 + } 584 + 585 + .book-page-header { 586 + position: absolute; 587 + top: var(--book-header-top); 588 + left: 50%; 589 + transform: translateX(-50%); 590 + margin: 0; 591 + width: var(--book-text-width); 592 + text-align: right; 593 + padding-right: 0.125rem; 594 + font-size: var(--book-header-font-size); 595 + line-height: 1; 596 + color: #1b1b1b; 597 + white-space: nowrap; 598 + } 599 + 600 + .book-page-text span { 601 + line-height: inherit; 602 + } 603 + 604 + /* Minecraft UI pass: square panels, pixel-style bevels, no soft shadows */ 605 + .hero, 606 + .sidebar .panel, 607 + .book-tile, 608 + .page-preview, 609 + .empty-state { 610 + border-radius: 0; 611 + border: 0.125rem solid var(--border); 612 + box-shadow: 613 + inset 0.0625rem 0.0625rem 0 rgba(255, 255, 255, 0.45), 614 + inset -0.0625rem -0.0625rem 0 rgba(0, 0, 0, 0.45); 615 + } 616 + 617 + .hero, 618 + .sidebar .panel, 619 + .page-preview, 620 + .empty-state { 621 + background: var(--surface); 622 + } 623 + 624 + .book-tile { 625 + background: #c0c0c0; 626 + } 627 + 628 + .book-badge, 629 + .book-icon, 630 + .category-link, 631 + .tag, 632 + .chip, 633 + .detail-link, 634 + button, 635 + .button, 636 + .link-reset, 637 + input[type="search"], 638 + select { 639 + border-radius: 0; 640 + } 641 + 642 + input[type="search"], 643 + select { 644 + border: 0.125rem solid var(--border); 645 + background: #e2e2e2; 646 + } 647 + 648 + button, 649 + .button { 650 + border: 0.125rem solid #1f1f1f; 651 + box-shadow: 652 + inset 0.0625rem 0.0625rem 0 #88cf67, 653 + inset -0.0625rem -0.0625rem 0 #274d1e; 654 + text-shadow: 0.0625rem 0.0625rem 0 rgba(0, 0, 0, 0.45); 655 + } 656 + 657 + .button.ghost, 658 + .link-reset { 659 + border: 0.125rem solid var(--border); 660 + background: #b7b7b7; 661 + color: var(--ink); 662 + box-shadow: 663 + inset 0.0625rem 0.0625rem 0 rgba(255, 255, 255, 0.4), 664 + inset -0.0625rem -0.0625rem 0 rgba(0, 0, 0, 0.4); 665 + } 666 + 667 + .category-link { 668 + border: 0.125rem solid var(--border); 669 + background: #c3c3c3; 670 + } 671 + 672 + .category-link.active { 673 + border-color: #275117; 674 + background: #78b65a; 675 + color: #0f2a09; 676 + } 677 + 678 + .book-icon { 679 + border: 0.125rem solid var(--border); 680 + box-shadow: 681 + inset 0.0625rem 0.0625rem 0 rgba(255, 255, 255, 0.4), 682 + inset -0.0625rem -0.0625rem 0 rgba(0, 0, 0, 0.45); 683 + } 684 + 685 + .book-icon:hover { 686 + transform: translateY(-0.0625rem); 687 + box-shadow: 688 + inset 0.0625rem 0.0625rem 0 rgba(255, 255, 255, 0.4), 689 + inset -0.0625rem -0.0625rem 0 rgba(0, 0, 0, 0.45); 690 + } 691 + 692 + .tag, 693 + .chip { 694 + border: 0.125rem solid var(--border); 695 + background: #cdcdcd; 696 + } 697 + 698 + .item { 699 + image-rendering: pixelated; 700 + } 701 + 702 + .enchanted { 703 + position: relative; 704 + display: inline-flex; 705 + } 706 + 707 + .enchanted::after { 708 + content: ""; 709 + position: absolute; 710 + inset: 0; 711 + pointer-events: none; 712 + background-image: url("/assets/image/glint.webp"); 713 + background-repeat: repeat; 714 + background-size: 4rem 4rem; 715 + background-position: 0 0; 716 + opacity: 0.6; 717 + mix-blend-mode: screen; 718 + mask-image: var(--mask, var(--book-mask)); 719 + mask-repeat: no-repeat; 720 + mask-size: 100% 100%; 721 + animation: glintMove 10.0s linear infinite, glintHue 10s linear infinite; 722 + } 723 + 724 + @keyframes glintMove { 725 + to { 726 + background-position: 6rem 6rem; 727 + } 728 + } 729 + 730 + @keyframes glintHue { 731 + 0% { 732 + filter: hue-rotate(0deg); 733 + } 734 + 735 + 50% { 736 + filter: hue-rotate(90deg); 737 + } 738 + 739 + 100% { 740 + filter: hue-rotate(0deg); 741 + } 742 + } 743 + 744 + @media (max-width: 61.25rem) { 745 + .layout { 746 + grid-template-columns: 1fr; 747 + } 748 + 749 + .sidebar .panel { 750 + position: static; 751 + } 752 + 753 + .search { 754 + grid-template-columns: 1fr; 755 + } 756 + } 757 + 758 + @media (max-width: 40rem) { 759 + .page { 760 + padding: 1.5rem 1rem 2.5rem; 761 + } 762 + 763 + .brand { 764 + flex-direction: column; 765 + align-items: flex-start; 766 + } 767 + 768 + .detail-hero { 769 + flex-direction: column; 770 + align-items: flex-start; 771 + } 772 + }
+780
src/web/pages.rs
··· 1 + use std::{ 2 + sync::{Arc, Mutex}, 3 + time::{SystemTime, UNIX_EPOCH}, 4 + }; 5 + 6 + use ahash::AHashSet; 7 + use askama::{Template, filters::HtmlSafe}; 8 + use askama_web::WebTemplate; 9 + use axum::{ 10 + Extension, 11 + extract::{Path, Query}, 12 + response::Redirect, 13 + }; 14 + use serde::Deserialize; 15 + use smol_str::SmolStr; 16 + 17 + use crate::{ 18 + library::{Library, category_from_location, item::WrittenBookTag}, 19 + web::TextureKind, 20 + }; 21 + 22 + #[derive(Debug, Template, WebTemplate)] 23 + #[template(path = "index.html", whitespace = "minimize")] 24 + pub struct IndexTemplate { 25 + pub texture_kind: TextureKind, 26 + pub book_count: usize, 27 + pub query: QueryView, 28 + pub view_label: String, 29 + pub results_label: String, 30 + pub total: usize, 31 + pub offset: usize, 32 + pub limit: usize, 33 + pub page_start: usize, 34 + pub page_end: usize, 35 + pub has_prev: bool, 36 + pub has_next: bool, 37 + pub prev_offset: usize, 38 + pub next_offset: usize, 39 + pub pager_query: String, 40 + pub books: Vec<BookCard>, 41 + pub categories: Vec<CategoryView>, 42 + pub authors: Vec<AuthorView>, 43 + pub git_hash: String, 44 + } 45 + 46 + #[derive(Debug, Template, WebTemplate)] 47 + #[template(path = "book.html", whitespace = "minimize")] 48 + pub struct BookTemplate { 49 + pub texture_kind: TextureKind, 50 + pub book: BookDetail, 51 + pub git_hash: String, 52 + pub back_href: String, 53 + } 54 + 55 + impl HtmlSafe for TextureKind {} 56 + 57 + #[derive(Debug, Deserialize, Default)] 58 + pub struct IndexQuery { 59 + q: Option<String>, 60 + scope: Option<String>, 61 + category: Option<String>, 62 + author: Option<String>, 63 + limit: Option<usize>, 64 + offset: Option<usize>, 65 + } 66 + 67 + #[derive(Debug, Deserialize, Default)] 68 + pub struct BookQuery { 69 + back: Option<String>, 70 + } 71 + 72 + #[derive(Debug)] 73 + pub struct QueryView { 74 + pub q: String, 75 + pub scope: String, 76 + pub category: String, 77 + pub author: String, 78 + pub has_category: bool, 79 + pub has_author: bool, 80 + pub active: bool, 81 + } 82 + 83 + #[derive(Debug)] 84 + pub struct CategoryView { 85 + pub name: String, 86 + pub count: usize, 87 + pub href: String, 88 + pub active: bool, 89 + } 90 + 91 + #[derive(Debug)] 92 + pub struct AuthorView { 93 + pub name: String, 94 + pub count: usize, 95 + } 96 + 97 + #[derive(Debug)] 98 + pub struct BookCard { 99 + pub title: String, 100 + pub author: String, 101 + pub author_href: String, 102 + pub hash: String, 103 + pub detail_href: String, 104 + } 105 + 106 + #[derive(Debug)] 107 + pub struct BookDetail { 108 + pub title: String, 109 + pub author: String, 110 + pub author_href: String, 111 + pub location: String, 112 + pub has_location: bool, 113 + pub category: String, 114 + pub category_href: String, 115 + pub has_category: bool, 116 + pub pages: Vec<PageView>, 117 + pub page_count: usize, 118 + } 119 + 120 + #[derive(Debug)] 121 + pub struct PageView { 122 + pub index: usize, 123 + pub html: HtmlText, 124 + } 125 + 126 + #[derive(Debug, Clone)] 127 + pub struct HtmlText(pub String); 128 + 129 + impl std::fmt::Display for HtmlText { 130 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 131 + f.write_str(&self.0) 132 + } 133 + } 134 + 135 + impl HtmlSafe for HtmlText {} 136 + 137 + const DEFAULT_LIMIT: usize = 25; 138 + const MAX_LIMIT: usize = 50; 139 + const SEARCH_MAX_RESULTS: usize = 200; 140 + 141 + #[derive(Debug)] 142 + struct FilterParams { 143 + query: String, 144 + scope: &'static str, 145 + category: Option<String>, 146 + author: Option<String>, 147 + limit: usize, 148 + offset: usize, 149 + author_norm: Option<String>, 150 + category_norm: Option<String>, 151 + } 152 + 153 + pub async fn index( 154 + Extension(texture_kind): Extension<TextureKind>, 155 + Extension(library): Extension<Arc<Mutex<Library>>>, 156 + Query(params): Query<IndexQuery>, 157 + ) -> IndexTemplate { 158 + let library = library.lock().expect("library mutex poisoned"); 159 + let book_count = library.book_count(); 160 + let git_hash = git_hash(); 161 + let params = FilterParams::from_query(params); 162 + 163 + let (mut books, total, results_label, offset_used, view_label): ( 164 + Vec<BookCard>, 165 + usize, 166 + String, 167 + usize, 168 + String, 169 + ) = if !params.query.is_empty() 170 + || params.author.is_some() 171 + || params.category.is_some() 172 + { 173 + let filtered = collect_candidates(&library, &params); 174 + let total = filtered.len(); 175 + let offset_used = params.offset.min(total); 176 + let books = filtered 177 + .into_iter() 178 + .skip(offset_used) 179 + .take(params.limit) 180 + .map(book_card) 181 + .collect(); 182 + let results_label = if params.query.is_empty() { 183 + format!("{0} books", total) 184 + } else { 185 + format!("Top {0} matches", total) 186 + }; 187 + let view_label = if params.query.is_empty() { 188 + filtered_view_label( 189 + params.author.as_deref(), 190 + params.category.as_deref(), 191 + ) 192 + } else { 193 + format!("Search results for \"{0}\"", params.query) 194 + }; 195 + (books, total, results_label, offset_used, view_label) 196 + } else { 197 + let total = book_count; 198 + let offset_used = params.offset.min(total); 199 + let books = library 200 + .all_books() 201 + .skip(offset_used) 202 + .take(params.limit) 203 + .map(book_card) 204 + .collect(); 205 + ( 206 + books, 207 + total, 208 + format!("{0} books", total), 209 + offset_used, 210 + String::from("All books"), 211 + ) 212 + }; 213 + 214 + let categories = build_categories(&library, params.category.as_deref()); 215 + let authors = build_authors(&library); 216 + let pager_query = build_pager_query( 217 + &params.query, 218 + params.scope, 219 + params.category.as_deref(), 220 + params.author.as_deref(), 221 + ); 222 + let has_category = params.category.is_some(); 223 + let has_author = params.author.is_some(); 224 + 225 + let active = !params.query.is_empty() 226 + || params.category.is_some() 227 + || params.author.is_some() 228 + || params.offset > 0; 229 + 230 + let page_start = if total == 0 { 0 } else { offset_used + 1 }; 231 + let page_end = if total == 0 { 232 + 0 233 + } else { 234 + (offset_used + params.limit).min(total) 235 + }; 236 + let has_prev = offset_used > 0; 237 + let has_next = offset_used + params.limit < total; 238 + let prev_offset = offset_used.saturating_sub(params.limit); 239 + let next_offset = offset_used + params.limit; 240 + let back_href = build_index_href( 241 + &params.query, 242 + params.scope, 243 + params.category.as_deref(), 244 + params.author.as_deref(), 245 + params.limit, 246 + offset_used, 247 + ); 248 + let encoded_back_href = encode_query_component(&back_href); 249 + for book in &mut books { 250 + book.detail_href = 251 + format!("/book/{0}?back={1}", book.hash, encoded_back_href); 252 + } 253 + 254 + IndexTemplate { 255 + texture_kind, 256 + book_count, 257 + query: QueryView { 258 + q: params.query.clone(), 259 + scope: params.scope.to_string(), 260 + category: params.category.clone().unwrap_or_default(), 261 + author: params.author.clone().unwrap_or_default(), 262 + has_category, 263 + has_author, 264 + active, 265 + }, 266 + view_label, 267 + results_label, 268 + total, 269 + offset: offset_used, 270 + limit: params.limit, 271 + page_start, 272 + page_end, 273 + has_prev, 274 + has_next, 275 + prev_offset, 276 + next_offset, 277 + pager_query, 278 + books, 279 + categories, 280 + authors, 281 + git_hash, 282 + } 283 + } 284 + 285 + pub async fn book( 286 + Extension(texture_kind): Extension<TextureKind>, 287 + Extension(library): Extension<Arc<Mutex<Library>>>, 288 + Path(hash): Path<String>, 289 + Query(book_query): Query<BookQuery>, 290 + ) -> Result<BookTemplate, axum::http::StatusCode> { 291 + let library = library.lock().expect("library mutex poisoned"); 292 + let git_hash = git_hash(); 293 + let back_href = normalize_back_href(book_query.back); 294 + let hash = parse_hash(&hash).ok_or(axum::http::StatusCode::BAD_REQUEST)?; 295 + let Some(book) = library.book_by_hash(hash) else { 296 + return Err(axum::http::StatusCode::NOT_FOUND); 297 + }; 298 + 299 + let detail = book_detail(book, &library); 300 + 301 + Ok(BookTemplate { 302 + texture_kind, 303 + book: detail, 304 + git_hash, 305 + back_href, 306 + }) 307 + } 308 + 309 + pub async fn random_book( 310 + Extension(library): Extension<Arc<Mutex<Library>>>, 311 + Query(params): Query<IndexQuery>, 312 + ) -> Redirect { 313 + let library = library.lock().expect("library mutex poisoned"); 314 + let params = FilterParams::from_query(params); 315 + let candidates = collect_candidates(&library, &params); 316 + 317 + if candidates.is_empty() { 318 + return Redirect::to("/"); 319 + } 320 + 321 + let back_href = build_index_href( 322 + &params.query, 323 + params.scope, 324 + params.category.as_deref(), 325 + params.author.as_deref(), 326 + params.limit, 327 + params.offset, 328 + ); 329 + let back_encoded = encode_query_component(&back_href); 330 + 331 + let seed = SystemTime::now() 332 + .duration_since(UNIX_EPOCH) 333 + .unwrap_or_default() 334 + .as_nanos() as usize; 335 + let idx = seed % candidates.len(); 336 + let hash = hex::encode(candidates[idx].hash()); 337 + 338 + Redirect::to(&format!("/book/{0}?back={1}", hash, back_encoded)) 339 + } 340 + 341 + impl FilterParams { 342 + fn from_query(query: IndexQuery) -> Self { 343 + let query_text = query.q.unwrap_or_default(); 344 + let query_text = query_text.trim().to_string(); 345 + let category = cleaned_param(query.category); 346 + let author = cleaned_param(query.author); 347 + Self { 348 + query: query_text, 349 + scope: parse_scope(query.scope.as_deref()), 350 + limit: clamp_limit(query.limit), 351 + offset: query.offset.unwrap_or(0), 352 + category_norm: category.as_deref().map(normalize_query), 353 + author_norm: author.as_deref().map(normalize_query), 354 + category, 355 + author, 356 + } 357 + } 358 + } 359 + 360 + fn collect_candidates<'a>( 361 + library: &'a Library, 362 + params: &FilterParams, 363 + ) -> Vec<&'a WrittenBookTag> { 364 + if !params.query.is_empty() { 365 + // Search results are narrowed by optional author/category filters. 366 + return search_books( 367 + library, 368 + params.scope, 369 + &params.query, 370 + SEARCH_MAX_RESULTS, 371 + ) 372 + .into_iter() 373 + .filter(|book| { 374 + matches_author(book, params.author_norm.as_deref()) 375 + && matches_category( 376 + book, 377 + library, 378 + params.category_norm.as_deref(), 379 + ) 380 + }) 381 + .collect(); 382 + } 383 + 384 + if let (Some(_author_name), Some(category_name)) = 385 + (params.author.as_deref(), params.category.as_deref()) 386 + { 387 + return library 388 + .books_in_category(category_name) 389 + .filter(|book| matches_author(book, params.author_norm.as_deref())) 390 + .collect(); 391 + } 392 + if let Some(author_name) = params.author.as_deref() { 393 + return library.books_by_author(author_name).collect(); 394 + } 395 + if let Some(category_name) = params.category.as_deref() { 396 + return library.books_in_category(category_name).collect(); 397 + } 398 + library.all_books().collect() 399 + } 400 + 401 + fn filtered_view_label(author: Option<&str>, category: Option<&str>) -> String { 402 + match (author, category) { 403 + (Some(author), Some(category)) => { 404 + format!("Author: {0} · Category: {1}", author, category) 405 + } 406 + (Some(author), None) => format!("Author: {0}", author), 407 + (None, Some(category)) => format!("Category: {0}", category), 408 + (None, None) => String::from("Filtered books"), 409 + } 410 + } 411 + 412 + fn build_categories( 413 + library: &Library, 414 + active: Option<&str>, 415 + ) -> Vec<CategoryView> { 416 + let mut categories: Vec<CategoryView> = library 417 + .categories() 418 + .into_iter() 419 + .map(|(name, count)| { 420 + let name_str = name.to_string(); 421 + CategoryView { 422 + active: active 423 + .map(|v| v.eq_ignore_ascii_case(&name_str)) 424 + .unwrap_or(false), 425 + href: format!( 426 + "/?category={0}", 427 + encode_query_component(&name_str) 428 + ), 429 + name: name_str, 430 + count, 431 + } 432 + }) 433 + .collect(); 434 + categories.sort_by(|a, b| { 435 + b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)) 436 + }); 437 + categories 438 + } 439 + 440 + fn build_authors(library: &Library) -> Vec<AuthorView> { 441 + let mut counts: ahash::AHashMap<SmolStr, (SmolStr, usize)> = 442 + ahash::AHashMap::new(); 443 + for book in library.all_books() { 444 + let raw = book.author.trim(); 445 + if raw.is_empty() { 446 + continue; 447 + } 448 + let key = SmolStr::new(raw.to_lowercase()); 449 + let entry = counts.entry(key).or_insert_with(|| { 450 + // Preserve first-seen casing for display. 451 + (SmolStr::new(raw), 0) 452 + }); 453 + entry.1 += 1; 454 + } 455 + 456 + let mut authors: Vec<AuthorView> = counts 457 + .into_iter() 458 + .map(|(_, (name, count))| AuthorView { 459 + name: name.to_string(), 460 + count, 461 + }) 462 + .collect(); 463 + authors.sort_by(|a, b| { 464 + author_sort_key(&a.name).cmp(&author_sort_key(&b.name)) 465 + }); 466 + authors 467 + } 468 + 469 + fn author_sort_key(name: &str) -> (u8, String) { 470 + let trimmed = name.trim(); 471 + let mut chars = trimmed.chars(); 472 + let starts_other = match chars.next() { 473 + Some(c) => !c.is_ascii_alphabetic(), 474 + None => true, 475 + }; 476 + let group = if starts_other { 1 } else { 0 }; 477 + (group, trimmed.to_lowercase()) 478 + } 479 + 480 + fn build_pager_query( 481 + q: &str, 482 + scope: &str, 483 + category: Option<&str>, 484 + author: Option<&str>, 485 + ) -> String { 486 + let mut parts = Vec::new(); 487 + if !q.is_empty() { 488 + parts.push(format!("q={0}", encode_query_component(q))); 489 + if scope != "all" { 490 + parts.push(format!("scope={0}", encode_query_component(scope))); 491 + } 492 + } 493 + if let Some(category) = category { 494 + parts.push(format!("category={0}", encode_query_component(category))); 495 + } 496 + if let Some(author) = author { 497 + parts.push(format!("author={0}", encode_query_component(author))); 498 + } 499 + if parts.is_empty() { 500 + String::new() 501 + } else { 502 + format!("{0}&", parts.join("&")) 503 + } 504 + } 505 + 506 + fn build_index_href( 507 + q: &str, 508 + scope: &str, 509 + category: Option<&str>, 510 + author: Option<&str>, 511 + limit: usize, 512 + offset: usize, 513 + ) -> String { 514 + let mut parts = Vec::new(); 515 + if !q.is_empty() { 516 + parts.push(format!("q={0}", encode_query_component(q))); 517 + if scope != "all" { 518 + parts.push(format!("scope={0}", encode_query_component(scope))); 519 + } 520 + } 521 + if let Some(category) = category { 522 + parts.push(format!("category={0}", encode_query_component(category))); 523 + } 524 + if let Some(author) = author { 525 + parts.push(format!("author={0}", encode_query_component(author))); 526 + } 527 + if limit != DEFAULT_LIMIT { 528 + parts.push(format!("limit={0}", limit)); 529 + } 530 + if offset > 0 { 531 + parts.push(format!("offset={0}", offset)); 532 + } 533 + if parts.is_empty() { 534 + "/".to_string() 535 + } else { 536 + format!("/?{0}", parts.join("&")) 537 + } 538 + } 539 + 540 + fn normalize_back_href(back: Option<String>) -> String { 541 + let Some(back) = back else { 542 + return "/".to_string(); 543 + }; 544 + let trimmed = back.trim(); 545 + 546 + // Allow only local paths. 547 + if trimmed.starts_with('/') && !trimmed.starts_with("//") { 548 + trimmed.to_string() 549 + } else { 550 + "/".to_string() 551 + } 552 + } 553 + 554 + struct BookMeta { 555 + author_href: String, 556 + location: String, 557 + has_location: bool, 558 + category: String, 559 + has_category: bool, 560 + category_href: String, 561 + } 562 + 563 + fn book_meta(book: &WrittenBookTag, library: &Library) -> BookMeta { 564 + let hash = book.hash(); 565 + let location = library 566 + .location_for_hash(&hash) 567 + .map(|s| s.to_string()) 568 + .unwrap_or_default(); 569 + let has_location = !location.is_empty(); 570 + let category = if has_location { 571 + category_from_location(&location) 572 + .map(|s| s.to_string()) 573 + .unwrap_or_default() 574 + } else { 575 + String::new() 576 + }; 577 + let has_category = !category.is_empty(); 578 + let category_href = if has_category { 579 + format!("/?category={0}", encode_query_component(&category)) 580 + } else { 581 + String::new() 582 + }; 583 + let author_href = 584 + format!("/?author={0}", encode_query_component(&book.author)); 585 + 586 + BookMeta { 587 + author_href, 588 + location, 589 + has_location, 590 + category, 591 + has_category, 592 + category_href, 593 + } 594 + } 595 + 596 + fn book_card(book: &WrittenBookTag) -> BookCard { 597 + let hash_hex = hex::encode(book.hash()); 598 + let author_href = 599 + format!("/?author={0}", encode_query_component(&book.author)); 600 + 601 + BookCard { 602 + title: book.title.clone(), 603 + author: book.author.clone(), 604 + author_href, 605 + hash: hash_hex.clone(), 606 + detail_href: format!("/book/{0}", hash_hex), 607 + } 608 + } 609 + 610 + fn book_detail(book: &WrittenBookTag, library: &Library) -> BookDetail { 611 + let meta = book_meta(book, library); 612 + let pages = book 613 + .pages 614 + .iter() 615 + .enumerate() 616 + .map(|(idx, page)| PageView { 617 + index: idx + 1, 618 + html: HtmlText(page.to_html()), 619 + }) 620 + .collect(); 621 + 622 + BookDetail { 623 + title: book.title.clone(), 624 + author: book.author.clone(), 625 + author_href: meta.author_href, 626 + location: meta.location, 627 + has_location: meta.has_location, 628 + category: meta.category, 629 + category_href: meta.category_href, 630 + has_category: meta.has_category, 631 + pages, 632 + page_count: book.pages.len(), 633 + } 634 + } 635 + 636 + fn clamp_limit(limit: Option<usize>) -> usize { 637 + limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) 638 + } 639 + 640 + fn parse_scope(scope: Option<&str>) -> &'static str { 641 + match scope.unwrap_or("all").to_ascii_lowercase().as_str() { 642 + "title" => "title", 643 + "author" => "author", 644 + "contents" => "contents", 645 + _ => "all", 646 + } 647 + } 648 + 649 + fn cleaned_param(value: Option<String>) -> Option<String> { 650 + value 651 + .map(|s| s.trim().to_string()) 652 + .filter(|s| !s.is_empty()) 653 + } 654 + 655 + fn normalize_query(raw: &str) -> String { 656 + raw.trim().to_lowercase() 657 + } 658 + 659 + fn search_books<'a>( 660 + library: &'a Library, 661 + scope: &str, 662 + query: &str, 663 + fetch: usize, 664 + ) -> Vec<&'a WrittenBookTag> { 665 + match scope { 666 + "title" => search_title_relaxed(library, query, fetch), 667 + "author" => library 668 + .fuzzy_author(query, fetch) 669 + .into_iter() 670 + .map(|(book, _)| book) 671 + .collect(), 672 + "contents" => library 673 + .fuzzy_contents(query, fetch) 674 + .into_iter() 675 + .map(|(book, _)| book) 676 + .collect(), 677 + _ => library 678 + .fuzzy(query, fetch) 679 + .into_iter() 680 + .map(|(book, _)| book) 681 + .collect(), 682 + } 683 + } 684 + 685 + fn search_title_relaxed<'a>( 686 + library: &'a Library, 687 + query: &str, 688 + fetch: usize, 689 + ) -> Vec<&'a WrittenBookTag> { 690 + let needle = normalize_query(query); 691 + if needle.is_empty() || fetch == 0 { 692 + return Vec::new(); 693 + } 694 + 695 + let mut out = Vec::new(); 696 + let mut seen: AHashSet<[u8; 20]> = AHashSet::new(); 697 + 698 + for book in library.all_books() { 699 + let title_norm = normalize_query(&book.title); 700 + if title_norm.contains(&needle) { 701 + let hash = book.hash(); 702 + if seen.insert(hash) { 703 + out.push(book); 704 + } 705 + } 706 + if out.len() >= fetch { 707 + return out; 708 + } 709 + } 710 + 711 + for (book, _) in library.fuzzy_title(query, fetch) { 712 + let hash = book.hash(); 713 + if seen.insert(hash) { 714 + out.push(book); 715 + } 716 + if out.len() >= fetch { 717 + break; 718 + } 719 + } 720 + 721 + out 722 + } 723 + 724 + fn matches_author(book: &WrittenBookTag, author_norm: Option<&str>) -> bool { 725 + let Some(author_norm) = author_norm else { 726 + return true; 727 + }; 728 + book.author.trim().eq_ignore_ascii_case(author_norm) 729 + } 730 + 731 + fn matches_category( 732 + book: &WrittenBookTag, 733 + library: &Library, 734 + category_norm: Option<&str>, 735 + ) -> bool { 736 + let Some(category_norm) = category_norm else { 737 + return true; 738 + }; 739 + let Some(location) = library.location_for_hash(&book.hash()) else { 740 + return false; 741 + }; 742 + let Some(category) = category_from_location(location) else { 743 + return false; 744 + }; 745 + category.trim().eq_ignore_ascii_case(category_norm) 746 + } 747 + 748 + fn parse_hash(hex_str: &str) -> Option<[u8; 20]> { 749 + let raw = hex::decode(hex_str).ok()?; 750 + if raw.len() != 20 { 751 + return None; 752 + } 753 + let mut out = [0u8; 20]; 754 + out.copy_from_slice(&raw); 755 + Some(out) 756 + } 757 + 758 + fn encode_query_component(raw: &str) -> String { 759 + let mut out = String::new(); 760 + for b in raw.as_bytes() { 761 + match *b { 762 + b'A'..=b'Z' 763 + | b'a'..=b'z' 764 + | b'0'..=b'9' 765 + | b'-' 766 + | b'_' 767 + | b'.' 768 + | b'~' => out.push(*b as char), 769 + b' ' => out.push('+'), 770 + _ => out.push_str(&format!("%{0:02X}", b)), 771 + } 772 + } 773 + out 774 + } 775 + 776 + fn git_hash() -> String { 777 + option_env!("NARA_GIT_HASH") 778 + .unwrap_or("unknown") 779 + .to_string() 780 + }
+17
templates/base.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="utf-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 + <meta name="color-scheme" content="light" /> 8 + <link rel="stylesheet" href="/assets/stylesheet.css" /> 9 + <title>{% block title %}nara{% endblock %}</title> 10 + {% block head %}{% endblock %} 11 + </head> 12 + 13 + <body class="font-minecraft"> 14 + {% block body %}{% endblock %} 15 + </body> 16 + 17 + </html>
+62
templates/book.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block title %}{{ book.title }} - nara{% endblock %} 4 + {% block head %} 5 + <link rel="icon" type="image/webp" href="/assets/image/{{ texture_kind }}/written_book.webp" /> 6 + <meta property="og:site_name" content="nara" /> 7 + <meta property="og:type" content="article" /> 8 + <meta property="og:title" content="{{ book.title }} - nara" /> 9 + <meta property="og:description" content="By {{ book.author }}. {{ book.page_count }} pages." /> 10 + <meta property="og:image" content="/assets/image/book_background.webp" /> 11 + <meta property="og:image:alt" content="Minecraft book page background" /> 12 + <meta name="twitter:card" content="summary_large_image" /> 13 + <meta name="twitter:title" content="{{ book.title }} - nara" /> 14 + <meta name="twitter:description" content="By {{ book.author }}. {{ book.page_count }} pages." /> 15 + <meta name="twitter:image" content="/assets/image/book_background.webp" /> 16 + {% endblock %} 17 + 18 + {% block body %} 19 + <main class="page detail" style="--book-mask: url('/assets/image/{{ texture_kind }}/written_book.webp')"> 20 + <header class="hero detail-hero"> 21 + <div class="brand"> 22 + <span class="enchanted book-badge"> 23 + <img class="item" src="/assets/image/{{ texture_kind }}/written_book.webp" alt=""> 24 + </span> 25 + <div> 26 + <p class="eyebrow">Book Detail</p> 27 + <h1 class="font-minecraft">{{ book.title }}</h1> 28 + <p class="meta">by <a href="{{ book.author_href }}">{{ book.author }}</a></p> 29 + <div class="meta-row"> 30 + {% if book.has_category %} 31 + <a class="tag-link" href="{{ book.category_href }}"><span class="tag">{{ book.category }}</span></a> 32 + {% endif %} 33 + {% if book.has_location %} 34 + <span class="tag subtle">{{ book.location }}</span> 35 + {% endif %} 36 + <span class="tag subtle">{{ book.page_count }} pages</span> 37 + </div> 38 + </div> 39 + </div> 40 + <div class="detail-actions"> 41 + <a class="button ghost" href="{{ back_href }}">Back to list</a> 42 + </div> 43 + </header> 44 + 45 + <section class="detail-body"> 46 + <div class="pages"> 47 + {% for page in book.pages %} 48 + <article class="book-page"> 49 + <div class="book-page-sprite"> 50 + <p class="book-page-header font-minecraft">Page {{ page.index }} of {{ book.page_count }}</p> 51 + <div class="book-page-text font-minecraft">{{ page.html }}</div> 52 + </div> 53 + </article> 54 + {% endfor %} 55 + </div> 56 + </section> 57 + 58 + <footer class="footer"> 59 + <p class="subtle">build {{ git_hash }}</p> 60 + </footer> 61 + </main> 62 + {% endblock %}
+167
templates/index.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block title %}nara{% endblock %} 4 + {% block head %} 5 + <link rel="icon" type="image/webp" href="/assets/image/{{ texture_kind }}/written_book.webp" /> 6 + <meta property="og:site_name" content="nara" /> 7 + <meta property="og:type" content="website" /> 8 + <meta property="og:title" content="nara - Book Archive" /> 9 + <meta property="og:description" content="Browse and search the nara library archive of Minecraft written books." /> 10 + <meta property="og:image" content="/assets/image/{{ texture_kind }}/written_book.webp" /> 11 + <meta property="og:image:alt" content="Minecraft written book icon" /> 12 + <meta name="twitter:card" content="summary" /> 13 + <meta name="twitter:title" content="nara - Book Archive" /> 14 + <meta name="twitter:description" content="Browse and search the nara library archive of Minecraft written books." /> 15 + <meta name="twitter:image" content="/assets/image/{{ texture_kind }}/written_book.webp" /> 16 + {% endblock %} 17 + 18 + {% block body %} 19 + <main class="page" style="--book-mask: url('/assets/image/{{ texture_kind }}/written_book.webp')"> 20 + <header class="hero"> 21 + <div class="brand"> 22 + <span class="enchanted book-badge"> 23 + <img class="item" src="/assets/image/{{ texture_kind }}/written_book.webp" alt=""> 24 + </span> 25 + <div> 26 + <p class="eyebrow">nara</p> 27 + <h1 class="font-minecraft">Book Archive</h1> 28 + <p class="subtle">{{ book_count }} books indexed</p> 29 + </div> 30 + </div> 31 + 32 + <form class="search" method="get" action="/"> 33 + <label class="field"> 34 + <span>Search</span> 35 + <input type="search" name="q" value="{{ query.q }}" placeholder="Title, author, or text" /> 36 + </label> 37 + <label class="field"> 38 + <span>Scope</span> 39 + <select name="scope"> 40 + <option value="all" {% if query.scope=="all" %}selected{% endif %}>All fields</option> 41 + <option value="title" {% if query.scope=="title" %}selected{% endif %}>Title</option> 42 + <option value="author" {% if query.scope=="author" %}selected{% endif %}>Author</option> 43 + <option value="contents" {% if query.scope=="contents" %}selected{% endif %}>Contents</option> 44 + </select> 45 + </label> 46 + <label class="field"> 47 + <span>Author filter</span> 48 + <select name="author"> 49 + <option value="" {% if !query.has_author %}selected{% endif %}>All authors</option> 50 + {% for a in authors %} 51 + <option value="{{ a.name }}" {% if query.author==a.name %}selected{% endif %}>{{ a.name }}</option> 52 + {% endfor %} 53 + </select> 54 + </label> 55 + <label class="field"> 56 + <span>Category filter</span> 57 + <select name="category"> 58 + <option value="" {% if !query.has_category %}selected{% endif %}>All categories</option> 59 + {% for c in categories %} 60 + <option value="{{ c.name }}" {% if query.category==c.name %}selected{% endif %}>{{ c.name }}</option> 61 + {% endfor %} 62 + </select> 63 + </label> 64 + <div class="actions"> 65 + <button type="submit">Search</button> 66 + {% if query.active %} 67 + <a class="link-reset" href="/">Clear</a> 68 + {% endif %} 69 + <button class="surprise-button" type="submit" formaction="/random">Surprise me</button> 70 + </div> 71 + </form> 72 + 73 + {% if query.has_category || query.has_author %} 74 + <div class="active-filters"> 75 + {% if query.has_category %} 76 + <span class="chip">Category: {{ query.category }}</span> 77 + {% endif %} 78 + {% if query.has_author %} 79 + <span class="chip">Author: {{ query.author }}</span> 80 + {% endif %} 81 + </div> 82 + {% endif %} 83 + </header> 84 + 85 + <section class="layout"> 86 + <aside class="sidebar"> 87 + <div class="panel"> 88 + <h2>Browse Categories</h2> 89 + <a class="all-link" href="/">All books</a> 90 + <ul class="category-list"> 91 + {% for c in categories %} 92 + <li> 93 + <a class="category-link {% if c.active %}active{% endif %}" href="{{ c.href }}"> 94 + <span>{{ c.name }}</span> 95 + <span class="count">{{ c.count }}</span> 96 + </a> 97 + </li> 98 + {% endfor %} 99 + </ul> 100 + </div> 101 + </aside> 102 + 103 + <section class="results"> 104 + <div class="results-header"> 105 + <div> 106 + <h2>{{ view_label }}</h2> 107 + <p class="subtle">{{ results_label }}</p> 108 + </div> 109 + {% if total > 0 %} 110 + <p class="subtle">Showing {{ page_start }}-{{ page_end }} of {{ total }}</p> 111 + {% endif %} 112 + </div> 113 + 114 + {% if has_prev || has_next %} 115 + <nav class="pager pager-top"> 116 + {% if has_prev %} 117 + <a class="button ghost" 118 + href="/?{{ pager_query }}offset={{ prev_offset }}&limit={{ limit }}">Previous</a> 119 + {% endif %} 120 + <span class="pager-spacer"></span> 121 + {% if has_next %} 122 + <a class="button ghost" href="/?{{ pager_query }}offset={{ next_offset }}&limit={{ limit }}">Next</a> 123 + {% endif %} 124 + </nav> 125 + {% endif %} 126 + 127 + {% if books.len() == 0 %} 128 + <div class="empty-state"> 129 + <p>No books found.</p> 130 + <p class="subtle">Try a different search term or browse a category.</p> 131 + </div> 132 + {% endif %} 133 + 134 + <div class="card-grid icon-grid"> 135 + {% for book in books %} 136 + <article class="book-tile"> 137 + <a class="book-icon" href="{{ book.detail_href }}" aria-label="Open {{ book.title }}"> 138 + <span class="enchanted"> 139 + <img class="item" src="/assets/image/{{ texture_kind }}/written_book.webp" alt=""> 140 + </span> 141 + </a> 142 + <h3 class="font-minecraft">{{ book.title }}</h3> 143 + <p class="meta">by <a href="{{ book.author_href }}">{{ book.author }}</a></p> 144 + </article> 145 + {% endfor %} 146 + </div> 147 + 148 + {% if has_prev || has_next %} 149 + <nav class="pager"> 150 + {% if has_prev %} 151 + <a class="button ghost" 152 + href="/?{{ pager_query }}offset={{ prev_offset }}&limit={{ limit }}">Previous</a> 153 + {% endif %} 154 + <span class="pager-spacer"></span> 155 + {% if has_next %} 156 + <a class="button ghost" href="/?{{ pager_query }}offset={{ next_offset }}&limit={{ limit }}">Next</a> 157 + {% endif %} 158 + </nav> 159 + {% endif %} 160 + </section> 161 + </section> 162 + 163 + <footer class="footer"> 164 + <p class="subtle">build {{ git_hash }}</p> 165 + </footer> 166 + </main> 167 + {% endblock %}