online Minecraft written book viewer

0.2.0 #1

closed opened by kokirigla.de targeting main from dev

cross version now wooo

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:uthy5qqccx3hdwxo7sriplmh/sh.tangled.repo.pull/3mg6uim3jl322
+3990 -1534
Diff #0
+4 -1
.gitignore
··· 1 1 /target 2 2 3 - realm.nbt 3 + # my local input data 4 + /worlds 5 + realm.nbt 6 + container.json.gz
+273 -30
Cargo.lock
··· 291 291 ] 292 292 293 293 [[package]] 294 + name = "borsh" 295 + version = "1.6.0" 296 + source = "registry+https://github.com/rust-lang/crates.io-index" 297 + checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" 298 + dependencies = [ 299 + "cfg_aliases", 300 + ] 301 + 302 + [[package]] 294 303 name = "brotli" 295 304 version = "8.0.2" 296 305 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 346 355 version = "1.0.4" 347 356 source = "registry+https://github.com/rust-lang/crates.io-index" 348 357 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 358 + 359 + [[package]] 360 + name = "cfg_aliases" 361 + version = "0.2.1" 362 + source = "registry+https://github.com/rust-lang/crates.io-index" 363 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 349 364 350 365 [[package]] 351 366 name = "chrono" ··· 426 441 checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" 427 442 428 443 [[package]] 444 + name = "console" 445 + version = "0.15.11" 446 + source = "registry+https://github.com/rust-lang/crates.io-index" 447 + checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" 448 + dependencies = [ 449 + "encode_unicode", 450 + "libc", 451 + "once_cell", 452 + "windows-sys 0.59.0", 453 + ] 454 + 455 + [[package]] 429 456 name = "const-oid" 430 457 version = "0.10.2" 431 458 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 561 588 checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 562 589 563 590 [[package]] 591 + name = "encode_unicode" 592 + version = "1.0.0" 593 + source = "registry+https://github.com/rust-lang/crates.io-index" 594 + checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" 595 + 596 + [[package]] 564 597 name = "equivalent" 565 598 version = "1.0.2" 566 599 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 577 610 ] 578 611 579 612 [[package]] 613 + name = "fastrand" 614 + version = "2.3.0" 615 + source = "registry+https://github.com/rust-lang/crates.io-index" 616 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 617 + 618 + [[package]] 580 619 name = "find-msvc-tools" 581 620 version = "0.1.9" 582 621 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 600 639 601 640 [[package]] 602 641 name = "foldhash" 603 - version = "0.1.5" 642 + version = "0.2.0" 604 643 source = "registry+https://github.com/rust-lang/crates.io-index" 605 - checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 644 + checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" 606 645 607 646 [[package]] 608 647 name = "form_urlencoded" ··· 672 711 673 712 [[package]] 674 713 name = "hashbrown" 675 - version = "0.15.5" 714 + version = "0.16.1" 676 715 source = "registry+https://github.com/rust-lang/crates.io-index" 677 - checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 716 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 678 717 dependencies = [ 679 718 "allocator-api2", 680 719 "equivalent", 681 720 "foldhash", 682 721 ] 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 722 690 723 [[package]] 691 724 name = "heck" ··· 852 885 ] 853 886 854 887 [[package]] 888 + name = "insta" 889 + version = "1.46.3" 890 + source = "registry+https://github.com/rust-lang/crates.io-index" 891 + checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" 892 + dependencies = [ 893 + "console", 894 + "once_cell", 895 + "similar", 896 + "tempfile", 897 + ] 898 + 899 + [[package]] 855 900 name = "is_terminal_polyfill" 856 901 version = "1.70.2" 857 902 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 896 941 checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" 897 942 898 943 [[package]] 944 + name = "linux-raw-sys" 945 + version = "0.12.1" 946 + source = "registry+https://github.com/rust-lang/crates.io-index" 947 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 948 + 949 + [[package]] 899 950 name = "lock_api" 900 951 version = "0.4.14" 901 952 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 912 963 913 964 [[package]] 914 965 name = "lru" 915 - version = "0.12.5" 966 + version = "0.16.3" 916 967 source = "registry+https://github.com/rust-lang/crates.io-index" 917 - checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 968 + checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" 918 969 dependencies = [ 919 - "hashbrown 0.15.5", 970 + "hashbrown 0.16.1", 920 971 ] 921 972 922 973 [[package]] ··· 960 1011 961 1012 [[package]] 962 1013 name = "nara" 963 - version = "0.1.0" 1014 + version = "0.2.0" 964 1015 dependencies = [ 965 1016 "ahash", 966 1017 "anyhow", ··· 972 1023 "hex", 973 1024 "html-escape", 974 1025 "lru", 1026 + "nara_core", 1027 + "nara_slurper_1_12_infinity", 1028 + "nara_slurper_1_12_world", 975 1029 "serde", 976 1030 "serde_json", 977 1031 "serde_with", ··· 986 1040 ] 987 1041 988 1042 [[package]] 1043 + name = "nara_core" 1044 + version = "0.2.0" 1045 + dependencies = [ 1046 + "hex", 1047 + "insta", 1048 + "nara_io", 1049 + "serde", 1050 + "serde_json", 1051 + "serde_with", 1052 + "sha1", 1053 + "uuid", 1054 + ] 1055 + 1056 + [[package]] 1057 + name = "nara_io" 1058 + version = "0.2.0" 1059 + dependencies = [ 1060 + "crab_nbt", 1061 + "flate2", 1062 + "serde", 1063 + "serde_json", 1064 + "thiserror", 1065 + ] 1066 + 1067 + [[package]] 1068 + name = "nara_mcr" 1069 + version = "0.2.0" 1070 + dependencies = [ 1071 + "crab_nbt", 1072 + "flate2", 1073 + "serde", 1074 + "thiserror", 1075 + ] 1076 + 1077 + [[package]] 1078 + name = "nara_slurper_1_12_core" 1079 + version = "0.2.0" 1080 + dependencies = [ 1081 + "nara_core", 1082 + "nara_mcr", 1083 + "serde", 1084 + ] 1085 + 1086 + [[package]] 1087 + name = "nara_slurper_1_12_infinity" 1088 + version = "0.2.0" 1089 + dependencies = [ 1090 + "nara_core", 1091 + "nara_io", 1092 + "nara_slurper_1_12_core", 1093 + "serde", 1094 + ] 1095 + 1096 + [[package]] 1097 + name = "nara_slurper_1_12_world" 1098 + version = "0.2.0" 1099 + dependencies = [ 1100 + "crab_nbt", 1101 + "insta", 1102 + "nara_core", 1103 + "nara_io", 1104 + "nara_mcr", 1105 + "nara_slurper_1_12_core", 1106 + "serde", 1107 + "serde_with", 1108 + "thiserror", 1109 + "tokio", 1110 + "tracing", 1111 + "uuid", 1112 + ] 1113 + 1114 + [[package]] 989 1115 name = "nu-ansi-term" 990 1116 version = "0.50.3" 991 1117 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1143 1269 ] 1144 1270 1145 1271 [[package]] 1272 + name = "rustix" 1273 + version = "1.1.4" 1274 + source = "registry+https://github.com/rust-lang/crates.io-index" 1275 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 1276 + dependencies = [ 1277 + "bitflags", 1278 + "errno", 1279 + "libc", 1280 + "linux-raw-sys", 1281 + "windows-sys 0.61.2", 1282 + ] 1283 + 1284 + [[package]] 1146 1285 name = "rustversion" 1147 1286 version = "1.0.22" 1148 1287 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1258 1397 1259 1398 [[package]] 1260 1399 name = "serde_with" 1261 - version = "3.16.1" 1400 + version = "3.17.0" 1262 1401 source = "registry+https://github.com/rust-lang/crates.io-index" 1263 - checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" 1402 + checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" 1264 1403 dependencies = [ 1265 1404 "base64", 1266 1405 "chrono", ··· 1277 1416 1278 1417 [[package]] 1279 1418 name = "serde_with_macros" 1280 - version = "3.16.1" 1419 + version = "3.17.0" 1281 1420 source = "registry+https://github.com/rust-lang/crates.io-index" 1282 - checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" 1421 + checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" 1283 1422 dependencies = [ 1284 1423 "darling", 1285 1424 "proc-macro2", ··· 1328 1467 version = "0.3.8" 1329 1468 source = "registry+https://github.com/rust-lang/crates.io-index" 1330 1469 checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 1470 + 1471 + [[package]] 1472 + name = "similar" 1473 + version = "2.7.0" 1474 + source = "registry+https://github.com/rust-lang/crates.io-index" 1475 + checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" 1331 1476 1332 1477 [[package]] 1333 1478 name = "slab" ··· 1343 1488 1344 1489 [[package]] 1345 1490 name = "smol_str" 1346 - version = "0.2.2" 1491 + version = "0.3.5" 1347 1492 source = "registry+https://github.com/rust-lang/crates.io-index" 1348 - checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" 1493 + checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17" 1349 1494 dependencies = [ 1350 - "serde", 1495 + "borsh", 1496 + "serde_core", 1351 1497 ] 1352 1498 1353 1499 [[package]] ··· 1384 1530 checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 1385 1531 1386 1532 [[package]] 1533 + name = "tempfile" 1534 + version = "3.26.0" 1535 + source = "registry+https://github.com/rust-lang/crates.io-index" 1536 + checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" 1537 + dependencies = [ 1538 + "fastrand", 1539 + "getrandom", 1540 + "once_cell", 1541 + "rustix", 1542 + "windows-sys 0.61.2", 1543 + ] 1544 + 1545 + [[package]] 1387 1546 name = "thiserror" 1388 1547 version = "2.0.18" 1389 1548 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1614 1773 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1615 1774 1616 1775 [[package]] 1776 + name = "uuid" 1777 + version = "1.21.0" 1778 + source = "registry+https://github.com/rust-lang/crates.io-index" 1779 + checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" 1780 + dependencies = [ 1781 + "js-sys", 1782 + "serde_core", 1783 + "wasm-bindgen", 1784 + ] 1785 + 1786 + [[package]] 1617 1787 name = "valuable" 1618 1788 version = "0.1.1" 1619 1789 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1746 1916 1747 1917 [[package]] 1748 1918 name = "windows-sys" 1919 + version = "0.59.0" 1920 + source = "registry+https://github.com/rust-lang/crates.io-index" 1921 + checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1922 + dependencies = [ 1923 + "windows-targets 0.52.6", 1924 + ] 1925 + 1926 + [[package]] 1927 + name = "windows-sys" 1749 1928 version = "0.60.2" 1750 1929 source = "registry+https://github.com/rust-lang/crates.io-index" 1751 1930 checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 1752 1931 dependencies = [ 1753 - "windows-targets", 1932 + "windows-targets 0.53.5", 1754 1933 ] 1755 1934 1756 1935 [[package]] ··· 1764 1943 1765 1944 [[package]] 1766 1945 name = "windows-targets" 1946 + version = "0.52.6" 1947 + source = "registry+https://github.com/rust-lang/crates.io-index" 1948 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1949 + dependencies = [ 1950 + "windows_aarch64_gnullvm 0.52.6", 1951 + "windows_aarch64_msvc 0.52.6", 1952 + "windows_i686_gnu 0.52.6", 1953 + "windows_i686_gnullvm 0.52.6", 1954 + "windows_i686_msvc 0.52.6", 1955 + "windows_x86_64_gnu 0.52.6", 1956 + "windows_x86_64_gnullvm 0.52.6", 1957 + "windows_x86_64_msvc 0.52.6", 1958 + ] 1959 + 1960 + [[package]] 1961 + name = "windows-targets" 1767 1962 version = "0.53.5" 1768 1963 source = "registry+https://github.com/rust-lang/crates.io-index" 1769 1964 checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 1770 1965 dependencies = [ 1771 1966 "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", 1967 + "windows_aarch64_gnullvm 0.53.1", 1968 + "windows_aarch64_msvc 0.53.1", 1969 + "windows_i686_gnu 0.53.1", 1970 + "windows_i686_gnullvm 0.53.1", 1971 + "windows_i686_msvc 0.53.1", 1972 + "windows_x86_64_gnu 0.53.1", 1973 + "windows_x86_64_gnullvm 0.53.1", 1974 + "windows_x86_64_msvc 0.53.1", 1780 1975 ] 1781 1976 1782 1977 [[package]] 1783 1978 name = "windows_aarch64_gnullvm" 1979 + version = "0.52.6" 1980 + source = "registry+https://github.com/rust-lang/crates.io-index" 1981 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1982 + 1983 + [[package]] 1984 + name = "windows_aarch64_gnullvm" 1784 1985 version = "0.53.1" 1785 1986 source = "registry+https://github.com/rust-lang/crates.io-index" 1786 1987 checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 1787 1988 1788 1989 [[package]] 1789 1990 name = "windows_aarch64_msvc" 1991 + version = "0.52.6" 1992 + source = "registry+https://github.com/rust-lang/crates.io-index" 1993 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1994 + 1995 + [[package]] 1996 + name = "windows_aarch64_msvc" 1790 1997 version = "0.53.1" 1791 1998 source = "registry+https://github.com/rust-lang/crates.io-index" 1792 1999 checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 1793 2000 1794 2001 [[package]] 1795 2002 name = "windows_i686_gnu" 2003 + version = "0.52.6" 2004 + source = "registry+https://github.com/rust-lang/crates.io-index" 2005 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 2006 + 2007 + [[package]] 2008 + name = "windows_i686_gnu" 1796 2009 version = "0.53.1" 1797 2010 source = "registry+https://github.com/rust-lang/crates.io-index" 1798 2011 checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 1799 2012 1800 2013 [[package]] 1801 2014 name = "windows_i686_gnullvm" 2015 + version = "0.52.6" 2016 + source = "registry+https://github.com/rust-lang/crates.io-index" 2017 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 2018 + 2019 + [[package]] 2020 + name = "windows_i686_gnullvm" 1802 2021 version = "0.53.1" 1803 2022 source = "registry+https://github.com/rust-lang/crates.io-index" 1804 2023 checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 2024 + 2025 + [[package]] 2026 + name = "windows_i686_msvc" 2027 + version = "0.52.6" 2028 + source = "registry+https://github.com/rust-lang/crates.io-index" 2029 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1805 2030 1806 2031 [[package]] 1807 2032 name = "windows_i686_msvc" ··· 1811 2036 1812 2037 [[package]] 1813 2038 name = "windows_x86_64_gnu" 2039 + version = "0.52.6" 2040 + source = "registry+https://github.com/rust-lang/crates.io-index" 2041 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 2042 + 2043 + [[package]] 2044 + name = "windows_x86_64_gnu" 1814 2045 version = "0.53.1" 1815 2046 source = "registry+https://github.com/rust-lang/crates.io-index" 1816 2047 checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 1817 2048 1818 2049 [[package]] 1819 2050 name = "windows_x86_64_gnullvm" 2051 + version = "0.52.6" 2052 + source = "registry+https://github.com/rust-lang/crates.io-index" 2053 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 2054 + 2055 + [[package]] 2056 + name = "windows_x86_64_gnullvm" 1820 2057 version = "0.53.1" 1821 2058 source = "registry+https://github.com/rust-lang/crates.io-index" 1822 2059 checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 2060 + 2061 + [[package]] 2062 + name = "windows_x86_64_msvc" 2063 + version = "0.52.6" 2064 + source = "registry+https://github.com/rust-lang/crates.io-index" 2065 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1823 2066 1824 2067 [[package]] 1825 2068 name = "windows_x86_64_msvc"
+66 -23
Cargo.toml
··· 1 + [workspace] 2 + resolver = "3" 3 + members = ["nara_io", "nara_slurper_1_12_core", "nara_slurper_1_12_infinity", "nara_slurper_1_12_world", "nara_core", "nara_mcr"] 4 + 5 + [workspace.package] 6 + version = "0.2.0" 7 + edition = "2024" 8 + 9 + [workspace.dependencies] 10 + ahash = "=0.8.12" 11 + anyhow = "=1.0.102" 12 + askama = "=0.15.4" 13 + askama_web = { version = "=0.15.1", features = ["axum-0.8", "tracing-0.1"] } 14 + axum = "=0.8.8" 15 + clap = { version = "=4.5.60", features = ["derive"] } 16 + crab_nbt = { version = "=0.2.11", features = ["serde"] } 17 + flate2 = "=1.1.9" 18 + hex = "=0.4.3" 19 + html-escape = "=0.2.13" 20 + insta = "=1.46.3" 21 + lru = "=0.16.3" 22 + nara_slurper_1_12_core = { path = "nara_slurper_1_12_core" } 23 + nara_io = { path = "nara_io" } 24 + nara_slurper_1_12_infinity = { path = "nara_slurper_1_12_infinity" } 25 + nara_slurper_1_12_world = { path = "nara_slurper_1_12_world" } 26 + nara_core = { path = "nara_core" } 27 + nara_mcr = { path = "nara_mcr" } 28 + serde = { version = "=1.0.228", features = ["derive"] } 29 + serde_json = "=1.0.149" 30 + serde_with = { version = "=3.17.0", features = ["hex"] } 31 + sha1 = "=0.11.0-rc.5" 32 + smol_str = "=0.3.5" 33 + strsim = "=0.11.1" 34 + thiserror = "=2.0.18" 35 + tokio = { version = "=1.49.0", features = ["full"] } 36 + tower-http = { version = "=0.6.8", features = ["compression-full"] } 37 + tracing = "=0.1.44" 38 + tracing-subscriber = "=0.3.22" 39 + uuid = { version = "=1.21.0", features = ["serde"] } 40 + 1 41 [package] 2 42 name = "nara" 3 - version = "0.1.0" 4 - edition = "2024" 43 + version.workspace = true 44 + edition.workspace = true 5 45 6 46 [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" 47 + ahash.workspace = true 48 + anyhow.workspace = true 49 + askama.workspace = true 50 + askama_web.workspace = true 51 + axum.workspace = true 52 + clap.workspace = true 53 + crab_nbt.workspace = true 54 + hex.workspace = true 55 + html-escape.workspace = true 56 + lru.workspace = true 57 + nara_core.workspace = true 58 + nara_slurper_1_12_infinity.workspace = true 59 + nara_slurper_1_12_world.workspace = true 60 + serde.workspace = true 61 + serde_json.workspace = true 62 + serde_with.workspace = true 63 + sha1.workspace = true 64 + smol_str.workspace = true 65 + strsim.workspace = true 66 + thiserror.workspace = true 67 + tokio.workspace = true 68 + tower-http.workspace = true 69 + tracing.workspace = true 70 + tracing-subscriber.workspace = true
+1 -75
README.md
··· 1 1 # nara 2 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" 3 + A cross-version Minecraft written book archiver and viewer.
+8 -4
justfile
··· 13 13 @cargo run --release -- {{ args }} 14 14 15 15 lint: 16 - @cargo clippy 17 - 16 + @cargo clippy --workspace 17 + 18 18 alias b := build 19 19 20 20 build: 21 21 @cargo build --release 22 22 23 + review: 24 + @cargo insta review --workspace 25 + 23 26 test: 24 - @cargo test 27 + @cargo nextest r --workspace 25 28 26 - ok: lint test 29 + ok: lint test 30 + @cargo fmt --check
+17
nara_core/Cargo.toml
··· 1 + [package] 2 + name = "nara_core" 3 + version.workspace = true 4 + edition.workspace = true 5 + 6 + [dependencies] 7 + hex.workspace = true 8 + nara_io.workspace = true 9 + serde.workspace = true 10 + serde_json.workspace = true 11 + serde_with.workspace = true 12 + sha1.workspace = true 13 + uuid.workspace = true 14 + 15 + [dev-dependencies] 16 + insta.workspace = true 17 + serde_json.workspace = true
+5
nara_core/README.md
··· 1 + # nara_core 2 + 3 + This is intended to be a 1:1 model of Minecraft: Java Edition's [text component format][text]. 4 + 5 + [text]: https://minecraft.wiki/w/Text_component_format
+154
nara_core/src/book.rs
··· 1 + use serde::{Deserialize, Deserializer, Serialize, Serializer}; 2 + use sha1::Digest as _; 3 + use uuid::Uuid; 4 + 5 + use crate::component::{Component, ComponentObject}; 6 + 7 + pub type BookHash = [u8; 20]; 8 + 9 + #[derive(Debug, Clone, Serialize, Deserialize)] 10 + pub struct Book { 11 + pub metadata: BookMetadata, 12 + pub content: BookContent, 13 + } 14 + 15 + impl Book { 16 + /// Computes the hash of the book's contents. 17 + /// 18 + /// Metadata (such as source, generation, or resolved flag) is not taken 19 + /// into account when calculating the hash. 20 + pub fn hash(&self) -> BookHash { 21 + self.content.hash() 22 + } 23 + } 24 + 25 + #[derive(Debug, Clone, Serialize, Deserialize)] 26 + pub struct BookMetadata { 27 + pub source: BookSource, 28 + } 29 + 30 + #[derive(Debug, Clone, Serialize, Deserialize)] 31 + #[serde(tag = "type", rename_all = "snake_case")] 32 + pub enum BookSource { 33 + /// From an Infinity Item Editor realm file. 34 + InfinityRealm, 35 + /// From a player's save data. 36 + PlayerData { 37 + uuid: Uuid, 38 + inventory: PlayerInventoryKind, 39 + slot: i8, 40 + }, 41 + /// From a placed block entity, such as a chest. 42 + BlockEntity { 43 + dimension: String, 44 + id: String, 45 + x: i32, 46 + y: i32, 47 + z: i32, 48 + slot: i8, 49 + }, 50 + Entity { 51 + dimension: String, 52 + id: String, 53 + x: f64, 54 + y: f64, 55 + z: f64, 56 + }, 57 + EntityInventory { 58 + dimension: String, 59 + id: String, 60 + x: f64, 61 + y: f64, 62 + z: f64, 63 + slot: i8, 64 + }, 65 + /// Inside an item at `slot` within another container. 66 + ItemBlockEntity { 67 + id: String, 68 + slot: i8, 69 + within: Box<BookSource>, 70 + }, 71 + } 72 + 73 + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 74 + #[serde(rename_all = "snake_case")] 75 + pub enum PlayerInventoryKind { 76 + Inventory, 77 + EnderChest, 78 + } 79 + 80 + #[derive(Debug, Clone, Serialize, Deserialize)] 81 + #[serde(rename_all = "snake_case")] 82 + pub struct BookContent { 83 + pub author: String, 84 + #[serde( 85 + serialize_with = "serialize_pages", 86 + deserialize_with = "deserialize_pages" 87 + )] 88 + pub pages: Vec<Component>, 89 + pub title: String, 90 + #[serde(default)] 91 + pub generation: BookGeneration, 92 + #[serde(default)] 93 + pub resolved: bool, 94 + } 95 + 96 + fn serialize_pages<S: Serializer>( 97 + pages: &[Component], 98 + serializer: S, 99 + ) -> Result<S::Ok, S::Error> { 100 + pages 101 + .iter() 102 + .map(|c| c.clone().into_object()) 103 + .collect::<Vec<ComponentObject>>() 104 + .serialize(serializer) 105 + } 106 + 107 + fn deserialize_pages<'de, D: Deserializer<'de>>( 108 + deserializer: D, 109 + ) -> Result<Vec<Component>, D::Error> { 110 + Vec::<ComponentObject>::deserialize(deserializer).map(|objs| { 111 + objs.into_iter() 112 + .map(|o| Component::Object(Box::new(o))) 113 + .collect() 114 + }) 115 + } 116 + 117 + impl BookContent { 118 + /// Computes the hash of the book's contents. 119 + /// 120 + /// Metadata (such as generation or resolved flag) is not taken into account 121 + /// when calculating the hash. 122 + pub fn hash(&self) -> BookHash { 123 + let mut ctx = sha1::Sha1::new(); 124 + 125 + #[inline(always)] 126 + fn put_str(ctx: &mut sha1::Sha1, s: &str) { 127 + ctx.update(s.as_bytes()); 128 + } 129 + 130 + #[inline(always)] 131 + fn put_vec_page(ctx: &mut sha1::Sha1, v: &[Component]) { 132 + for c in v { 133 + let s = c.normalize(); 134 + put_str(ctx, &s); 135 + } 136 + } 137 + 138 + put_str(&mut ctx, &self.author); 139 + put_str(&mut ctx, &self.title); 140 + put_vec_page(&mut ctx, &self.pages); 141 + 142 + ctx.finalize().0 143 + } 144 + } 145 + 146 + #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] 147 + #[serde(rename_all = "snake_case")] 148 + pub enum BookGeneration { 149 + #[default] 150 + Original, 151 + CopyOfOriginal, 152 + CopyOfCopy, 153 + Tattered, 154 + }
+165
nara_core/src/color.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 4 + #[serde(rename_all = "snake_case")] 5 + pub enum NamedColor { 6 + Black, 7 + DarkBlue, 8 + DarkGreen, 9 + DarkAqua, 10 + DarkRed, 11 + DarkPurple, 12 + Gold, 13 + Gray, 14 + DarkGray, 15 + Blue, 16 + Green, 17 + Aqua, 18 + Red, 19 + LightPurple, 20 + Yellow, 21 + White, 22 + } 23 + 24 + impl NamedColor { 25 + pub fn as_hex(&self) -> [u8; 3] { 26 + match self { 27 + NamedColor::Black => [0x00, 0x00, 0x00], 28 + NamedColor::DarkBlue => [0x00, 0x00, 0xAA], 29 + NamedColor::DarkGreen => [0x00, 0xAA, 0x00], 30 + NamedColor::DarkAqua => [0x00, 0xAA, 0xAA], 31 + NamedColor::DarkRed => [0xAA, 0x00, 0x00], 32 + NamedColor::DarkPurple => [0xAA, 0x00, 0xAA], 33 + NamedColor::Gold => [0xFF, 0xAA, 0x00], 34 + NamedColor::Gray => [0xAA, 0xAA, 0xAA], 35 + NamedColor::DarkGray => [0x55, 0x55, 0x55], 36 + NamedColor::Blue => [0x55, 0x55, 0xFF], 37 + NamedColor::Green => [0x55, 0xFF, 0x55], 38 + NamedColor::Aqua => [0x55, 0xFF, 0xFF], 39 + NamedColor::Red => [0xFF, 0x55, 0x55], 40 + NamedColor::LightPurple => [0xFF, 0x55, 0xFF], 41 + NamedColor::Yellow => [0xFF, 0xFF, 0x55], 42 + NamedColor::White => [0xFF, 0xFF, 0xFF], 43 + } 44 + } 45 + 46 + pub fn as_hex_string(&self) -> String { 47 + hex_bytes_to_string(self.as_hex()) 48 + } 49 + 50 + pub fn from_hex(hex: [u8; 3]) -> Option<Self> { 51 + match hex { 52 + [0x00, 0x00, 0x00] => Some(NamedColor::Black), 53 + [0x00, 0x00, 0xAA] => Some(NamedColor::DarkBlue), 54 + [0x00, 0xAA, 0x00] => Some(NamedColor::DarkGreen), 55 + [0x00, 0xAA, 0xAA] => Some(NamedColor::DarkAqua), 56 + [0xAA, 0x00, 0x00] => Some(NamedColor::DarkRed), 57 + [0xAA, 0x00, 0xAA] => Some(NamedColor::DarkPurple), 58 + [0xFF, 0xAA, 0x00] => Some(NamedColor::Gold), 59 + [0xAA, 0xAA, 0xAA] => Some(NamedColor::Gray), 60 + [0x55, 0x55, 0x55] => Some(NamedColor::DarkGray), 61 + [0x55, 0x55, 0xFF] => Some(NamedColor::Blue), 62 + [0x55, 0xFF, 0x55] => Some(NamedColor::Green), 63 + [0x55, 0xFF, 0xFF] => Some(NamedColor::Aqua), 64 + [0xFF, 0x55, 0x55] => Some(NamedColor::Red), 65 + [0xFF, 0x55, 0xFF] => Some(NamedColor::LightPurple), 66 + [0xFF, 0xFF, 0x55] => Some(NamedColor::Yellow), 67 + [0xFF, 0xFF, 0xFF] => Some(NamedColor::White), 68 + _ => None, 69 + } 70 + } 71 + 72 + pub fn from_name(s: &str) -> Option<Self> { 73 + let normalized = s.trim().to_ascii_lowercase().replace([' ', '-'], "_"); 74 + 75 + match normalized.as_str() { 76 + "black" => Some(NamedColor::Black), 77 + "dark_blue" => Some(NamedColor::DarkBlue), 78 + "dark_green" => Some(NamedColor::DarkGreen), 79 + "dark_aqua" => Some(NamedColor::DarkAqua), 80 + "dark_red" => Some(NamedColor::DarkRed), 81 + "dark_purple" => Some(NamedColor::DarkPurple), 82 + "gold" => Some(NamedColor::Gold), 83 + "gray" | "grey" => Some(NamedColor::Gray), 84 + "dark_gray" | "dark_grey" => Some(NamedColor::DarkGray), 85 + "blue" => Some(NamedColor::Blue), 86 + "green" => Some(NamedColor::Green), 87 + "aqua" => Some(NamedColor::Aqua), 88 + "red" => Some(NamedColor::Red), 89 + "light_purple" => Some(NamedColor::LightPurple), 90 + "yellow" => Some(NamedColor::Yellow), 91 + "white" => Some(NamedColor::White), 92 + _ => None, 93 + } 94 + } 95 + } 96 + 97 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 98 + #[serde(try_from = "String", into = "String")] 99 + pub enum Color { 100 + Named(NamedColor), 101 + Hex(String), 102 + } 103 + 104 + impl Color { 105 + pub fn as_hex(&self) -> Result<[u8; 3], String> { 106 + match self { 107 + Color::Named(named) => Ok(named.as_hex()), 108 + Color::Hex(hex) => hex_string_to_bytes(hex), 109 + } 110 + } 111 + 112 + pub fn as_hex_string(&self) -> String { 113 + match self { 114 + Color::Named(named) => named.as_hex_string(), 115 + Color::Hex(hex) => match hex_string_to_bytes(hex) { 116 + Ok(bytes) => hex_bytes_to_string(bytes), 117 + Err(_) => hex.clone(), 118 + }, 119 + } 120 + } 121 + } 122 + 123 + impl TryFrom<String> for Color { 124 + type Error = String; 125 + 126 + fn try_from(value: String) -> Result<Self, Self::Error> { 127 + if let Some(named) = NamedColor::from_name(&value) { 128 + return Ok(Color::Named(named)); 129 + } 130 + 131 + let bytes = hex_string_to_bytes(&value)?; 132 + if let Some(named) = NamedColor::from_hex(bytes) { 133 + Ok(Color::Named(named)) 134 + } else { 135 + Ok(Color::Hex(hex_bytes_to_string(bytes))) 136 + } 137 + } 138 + } 139 + 140 + impl From<Color> for String { 141 + fn from(value: Color) -> Self { 142 + value.as_hex_string() 143 + } 144 + } 145 + 146 + fn hex_string_to_bytes(s: &str) -> Result<[u8; 3], String> { 147 + let s = s.trim().strip_prefix('#').unwrap_or(s.trim()); 148 + 149 + if s.len() != 6 { 150 + return Err(format!("expected 6 hex characters, got {}", s.len())); 151 + } 152 + 153 + let r = u8::from_str_radix(&s[0..2], 16) 154 + .map_err(|_| "invalid red channel".to_string())?; 155 + let g = u8::from_str_radix(&s[2..4], 16) 156 + .map_err(|_| "invalid green channel".to_string())?; 157 + let b = u8::from_str_radix(&s[4..6], 16) 158 + .map_err(|_| "invalid blue channel".to_string())?; 159 + 160 + Ok([r, g, b]) 161 + } 162 + 163 + fn hex_bytes_to_string(bytes: [u8; 3]) -> String { 164 + format!("#{:02X}{:02X}{:02X}", bytes[0], bytes[1], bytes[2]) 165 + }
+579
nara_core/src/component.rs
··· 1 + use serde::{ 2 + Deserialize, Deserializer, Serialize, 3 + de::{ 4 + MapAccess, SeqAccess, Visitor, 5 + value::{MapAccessDeserializer, SeqAccessDeserializer}, 6 + }, 7 + }; 8 + 9 + #[derive(Debug, Clone, Serialize)] 10 + #[serde(untagged)] 11 + pub enum Component { 12 + String(String), 13 + Object(Box<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(flatten)] 21 + text: Option<TextComponent>, 22 + #[serde(flatten)] 23 + translation: Option<TranslationComponent>, 24 + // DO NOT FLATTEN THE SCORE COMPONENT 25 + score: Option<ScoreComponent>, 26 + #[serde(flatten)] 27 + selector: Option<SelectorComponent>, 28 + #[serde(flatten)] 29 + keybind: Option<KeybindComponent>, 30 + #[serde(flatten)] 31 + nbt: Option<NbtComponent>, 32 + #[serde(flatten)] 33 + object: Option<ObjectComponent>, 34 + #[serde(flatten)] 35 + formatting: ComponentFormatting, 36 + #[serde(default, rename = "extra", skip_serializing_if = "Vec::is_empty")] 37 + children: Vec<Component>, 38 + } 39 + 40 + #[serde_with::skip_serializing_none] 41 + #[derive(Debug, Clone, Serialize, Deserialize)] 42 + pub struct ComponentFormatting { 43 + color: Option<crate::color::Color>, 44 + font: Option<String>, 45 + bold: Option<bool>, 46 + italic: Option<bool>, 47 + underlined: Option<bool>, 48 + strikethrough: Option<bool>, 49 + obfuscated: Option<bool>, 50 + } 51 + 52 + #[derive(Debug, Clone, Serialize, Deserialize)] 53 + pub struct TextComponent { 54 + text: String, 55 + } 56 + 57 + #[serde_with::skip_serializing_none] 58 + #[derive(Debug, Clone, Serialize, Deserialize)] 59 + pub struct TranslationComponent { 60 + translate: String, 61 + fallback: Option<String>, 62 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 63 + with: Vec<Component>, 64 + } 65 + 66 + #[serde_with::skip_serializing_none] 67 + #[derive(Debug, Clone, Serialize, Deserialize)] 68 + pub struct ScoreComponent { 69 + name: String, 70 + objective: String, 71 + } 72 + 73 + #[serde_with::skip_serializing_none] 74 + #[derive(Debug, Clone, Serialize, Deserialize)] 75 + pub struct SelectorComponent { 76 + selector: String, 77 + separator: Option<Box<Component>>, 78 + } 79 + 80 + #[serde_with::skip_serializing_none] 81 + #[derive(Debug, Clone, Serialize, Deserialize)] 82 + pub struct KeybindComponent { 83 + keybind: String, 84 + } 85 + 86 + #[derive(Debug, Clone, Serialize, Deserialize)] 87 + #[serde(rename_all = "snake_case")] 88 + pub enum NbtComponentSource { 89 + Block, 90 + Entity, 91 + Storage, 92 + } 93 + 94 + #[serde_with::skip_serializing_none] 95 + #[derive(Debug, Clone, Serialize, Deserialize)] 96 + pub struct NbtComponent { 97 + source: Option<NbtComponentSource>, 98 + #[serde(rename = "nbt")] 99 + path: String, 100 + interpret: Option<bool>, 101 + separator: Option<Box<Component>>, 102 + entity: Option<String>, 103 + block: Option<String>, 104 + storage: Option<String>, 105 + } 106 + 107 + #[derive(Debug, Clone, Serialize, Deserialize)] 108 + #[serde(tag = "object", rename_all = "snake_case")] 109 + pub enum ObjectComponent { 110 + Atlas(AtlasObject), 111 + Player(PlayerObject), 112 + } 113 + 114 + #[serde_with::skip_serializing_none] 115 + #[derive(Debug, Clone, Serialize, Deserialize)] 116 + pub struct AtlasObject { 117 + atlas: Option<String>, 118 + sprite: String, 119 + } 120 + 121 + #[derive(Debug, Clone, Serialize, Deserialize)] 122 + #[serde(untagged)] 123 + pub enum PlayerProfileOrName { 124 + Profile(crate::profile::PlayerProfile), 125 + Name(String), 126 + } 127 + 128 + #[serde_with::skip_serializing_none] 129 + #[derive(Debug, Clone, Serialize, Deserialize)] 130 + pub struct PlayerObject { 131 + player: PlayerProfileOrName, 132 + hat: Option<bool>, 133 + } 134 + 135 + impl Component { 136 + /// Recursively converts any `Component` into its `ComponentObject` form, 137 + /// collapsing `String` variants into `{text: "..."}` objects and flattening 138 + /// `Array` variants so the first element becomes the root and the rest 139 + /// become children. 140 + pub fn into_object(self) -> ComponentObject { 141 + match self { 142 + Component::String(s) => raw_text_object(s, None), 143 + Component::Object(mut obj) => { 144 + obj.children = obj 145 + .children 146 + .into_iter() 147 + .map(|c| Component::Object(Box::new(c.into_object()))) 148 + .collect(); 149 + *obj 150 + } 151 + Component::Array(mut items) => { 152 + if items.is_empty() { 153 + return raw_text_object(String::new(), None); 154 + } 155 + let mut obj = items.remove(0).into_object(); 156 + obj.children.extend( 157 + items 158 + .into_iter() 159 + .map(|c| Component::Object(Box::new(c.into_object()))), 160 + ); 161 + obj 162 + } 163 + } 164 + } 165 + 166 + pub fn to_plain_text(&self) -> String { 167 + match self { 168 + Component::String(s) => s.clone(), 169 + Component::Array(items) => { 170 + items.iter().map(|c| c.to_plain_text()).collect() 171 + } 172 + Component::Object(obj) => { 173 + let mut s = 174 + obj.text.as_ref().map_or(String::new(), |t| t.text.clone()); 175 + for child in &obj.children { 176 + s.push_str(&child.to_plain_text()); 177 + } 178 + s 179 + } 180 + } 181 + } 182 + 183 + pub fn normalize(&self) -> String { 184 + let text = self.to_plain_text(); 185 + strip_section_codes(&text) 186 + } 187 + } 188 + 189 + /// Removes legacy section symbol formatting codes. 190 + fn strip_section_codes(s: &str) -> String { 191 + let mut out = String::with_capacity(s.len()); 192 + let mut chars = s.chars(); 193 + while let Some(c) = chars.next() { 194 + if c == SECTION_SIGN { 195 + // Skip formatting code character if present. 196 + chars.next(); 197 + continue; 198 + } 199 + out.push(c); 200 + } 201 + out 202 + } 203 + 204 + /// Parses a page string into a component, handling stringified JSON. 205 + pub fn parse_component_from_str(raw: &str) -> Component { 206 + let mut current = raw.trim().to_string(); 207 + if current.is_empty() { 208 + return Component::String(String::new()); 209 + } 210 + if let Some(extracted) = extract_text_prefix(&current) { 211 + return expand_legacy(legacy_or_plain(&extracted)); 212 + } 213 + 214 + // Try parsing as JSON component once. 215 + if let Ok(component) = serde_json::from_str::<Component>(&current) { 216 + return expand_legacy(component); 217 + } 218 + 219 + // Try parsing as a JSON string, then re-parse if it was a JSON string that 220 + // itself contains JSON (stringified JSON). 221 + for _ in 0..3 { 222 + let s = match serde_json::from_str::<String>(&current) { 223 + Ok(v) => v, 224 + Err(_) => break, 225 + }; 226 + let s_trim = s.trim(); 227 + if looks_like_json(s_trim) { 228 + current = s_trim.to_string(); 229 + if let Ok(component) = serde_json::from_str::<Component>(&current) { 230 + return expand_legacy(component); 231 + } 232 + continue; 233 + } 234 + return expand_legacy(legacy_or_plain(&s)); 235 + } 236 + 237 + expand_legacy(legacy_or_plain(raw)) 238 + } 239 + 240 + fn looks_like_json(s: &str) -> bool { 241 + s.starts_with('{') || s.starts_with('[') 242 + } 243 + 244 + fn extract_text_prefix(s: &str) -> Option<String> { 245 + let s = s.trim_start(); 246 + if !s.starts_with("text") { 247 + return None; 248 + } 249 + let mut rest = s[4..].trim_start(); 250 + if rest.starts_with(':') { 251 + rest = rest[1..].trim_start(); 252 + } 253 + let start = rest.find('"')?; 254 + let rest = &rest[start..]; 255 + if let Ok(parsed) = serde_json::from_str::<String>(rest) { 256 + return Some(parsed); 257 + } 258 + if let Some(end) = rest[1..].rfind('"') { 259 + let slice = &rest[..end + 2]; 260 + if let Ok(parsed) = serde_json::from_str::<String>(slice) { 261 + return Some(parsed); 262 + } 263 + let content = &rest[1..end + 1]; 264 + return Some(unescape_json_string_lossy(content)); 265 + } 266 + None 267 + } 268 + 269 + fn unescape_json_string_lossy(s: &str) -> String { 270 + let mut out = String::with_capacity(s.len()); 271 + let mut chars = s.chars(); 272 + while let Some(c) = chars.next() { 273 + if c != '\\' { 274 + out.push(c); 275 + continue; 276 + } 277 + match chars.next() { 278 + Some('"') => out.push('"'), 279 + Some('\\') => out.push('\\'), 280 + Some('/') => out.push('/'), 281 + Some('b') => out.push('\u{0008}'), 282 + Some('f') => out.push('\u{000C}'), 283 + Some('n') => out.push('\n'), 284 + Some('r') => out.push('\r'), 285 + Some('t') => out.push('\t'), 286 + Some('u') => { 287 + let mut hex = String::new(); 288 + for _ in 0..4 { 289 + if let Some(h) = chars.next() { 290 + hex.push(h); 291 + } else { 292 + break; 293 + } 294 + } 295 + if let Ok(code) = u16::from_str_radix(&hex, 16) 296 + && let Some(ch) = char::from_u32(code as u32) 297 + { 298 + out.push(ch); 299 + } 300 + } 301 + Some(other) => out.push(other), 302 + None => break, 303 + } 304 + } 305 + out 306 + } 307 + 308 + fn legacy_or_plain(s: &str) -> Component { 309 + if s.contains(SECTION_SIGN) { 310 + parse_legacy_section_text(s) 311 + } else { 312 + Component::String(s.to_string()) 313 + } 314 + } 315 + 316 + fn expand_legacy(component: Component) -> Component { 317 + match component { 318 + Component::String(s) => legacy_or_plain(&s), 319 + Component::Array(items) => { 320 + Component::Array(items.into_iter().map(expand_legacy).collect()) 321 + } 322 + Component::Object(mut obj) => { 323 + if obj.translation.is_none() 324 + && let Some(ref tc) = obj.text 325 + && tc.text.contains('§') 326 + { 327 + let expanded = parse_legacy_section_text_with_color( 328 + &tc.text.clone(), 329 + obj.formatting.color.clone(), 330 + ); 331 + return merge_expanded_with_extra(expanded, obj.children); 332 + } 333 + obj.children = 334 + obj.children.into_iter().map(expand_legacy).collect(); 335 + if let Some(ref mut trans) = obj.translation { 336 + trans.with = trans.with.drain(..).map(expand_legacy).collect(); 337 + } 338 + Component::Object(obj) 339 + } 340 + } 341 + } 342 + 343 + fn merge_expanded_with_extra( 344 + expanded: Component, 345 + extra: Vec<Component>, 346 + ) -> Component { 347 + if extra.is_empty() { 348 + return expanded; 349 + } 350 + match expanded { 351 + Component::Array(mut items) => { 352 + items.extend(extra.into_iter().map(expand_legacy)); 353 + Component::Array(items) 354 + } 355 + other => Component::Array( 356 + std::iter::once(other) 357 + .chain(extra.into_iter().map(expand_legacy)) 358 + .collect(), 359 + ), 360 + } 361 + } 362 + 363 + fn raw_text_object( 364 + text: String, 365 + color: Option<crate::color::Color>, 366 + ) -> ComponentObject { 367 + ComponentObject { 368 + text: Some(TextComponent { text }), 369 + translation: None, 370 + score: None, 371 + selector: None, 372 + keybind: None, 373 + nbt: None, 374 + object: None, 375 + formatting: ComponentFormatting { 376 + color, 377 + font: None, 378 + bold: None, 379 + italic: None, 380 + underlined: None, 381 + strikethrough: None, 382 + obfuscated: None, 383 + }, 384 + children: Vec::new(), 385 + } 386 + } 387 + 388 + fn make_text_object( 389 + text: String, 390 + color: Option<crate::color::Color>, 391 + ) -> Component { 392 + Component::Object(Box::new(raw_text_object(text, color))) 393 + } 394 + 395 + fn parse_legacy_section_text(s: &str) -> Component { 396 + parse_legacy_section_text_with_color(s, None) 397 + } 398 + 399 + fn parse_legacy_section_text_with_color( 400 + s: &str, 401 + initial_color: Option<crate::color::Color>, 402 + ) -> Component { 403 + let mut out: Vec<Component> = Vec::new(); 404 + let mut buf = String::new(); 405 + let mut color: Option<crate::color::Color> = initial_color; 406 + 407 + let mut chars = s.chars(); 408 + while let Some(c) = chars.next() { 409 + if c == SECTION_SIGN 410 + && let Some(code) = chars.next() 411 + { 412 + if !buf.is_empty() { 413 + out.push(make_text_object( 414 + std::mem::take(&mut buf), 415 + color.clone(), 416 + )); 417 + } 418 + 419 + match legacy_color_name(code) { 420 + Some(name) => { 421 + color = crate::color::Color::try_from(name).ok(); 422 + } 423 + None => { 424 + if code == LEGACY_RESET_LOWER || code == LEGACY_RESET_UPPER 425 + { 426 + color = None; 427 + } 428 + } 429 + } 430 + continue; 431 + } 432 + buf.push(c); 433 + } 434 + 435 + if !buf.is_empty() { 436 + out.push(make_text_object(buf, color)); 437 + } 438 + 439 + if out.is_empty() { 440 + Component::String(String::new()) 441 + } else if out.len() == 1 { 442 + out.pop() 443 + .unwrap_or_else(|| Component::String(String::new())) 444 + } else { 445 + Component::Array(out) 446 + } 447 + } 448 + 449 + fn legacy_color_name(code: char) -> Option<String> { 450 + let name = match code { 451 + '0' => "black", 452 + '1' => "dark_blue", 453 + '2' => "dark_green", 454 + '3' => "dark_aqua", 455 + '4' => "dark_red", 456 + '5' => "dark_purple", 457 + '6' => "gold", 458 + '7' => "gray", 459 + '8' => "dark_gray", 460 + '9' => "blue", 461 + 'a' | 'A' => "green", 462 + 'b' | 'B' => "aqua", 463 + 'c' | 'C' => "red", 464 + 'd' | 'D' => "light_purple", 465 + 'e' | 'E' => "yellow", 466 + 'f' | 'F' => "white", 467 + _ => return None, 468 + }; 469 + Some(name.to_string()) 470 + } 471 + 472 + const SECTION_SIGN: char = '§'; 473 + const LEGACY_RESET_LOWER: char = 'r'; 474 + const LEGACY_RESET_UPPER: char = 'R'; 475 + 476 + impl<'de> Deserialize<'de> for Component { 477 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 478 + where 479 + D: Deserializer<'de>, 480 + { 481 + struct ComponentVisitor; 482 + 483 + impl<'de> Visitor<'de> for ComponentVisitor { 484 + type Value = Component; 485 + 486 + fn expecting( 487 + &self, 488 + f: &mut std::fmt::Formatter, 489 + ) -> std::fmt::Result { 490 + f.write_str("a Minecraft text component") 491 + } 492 + 493 + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 494 + where 495 + E: serde::de::Error, 496 + { 497 + Ok(parse_component_from_str(v)) 498 + } 499 + 500 + fn visit_string<E>(self, v: String) -> Result<Self::Value, E> 501 + where 502 + E: serde::de::Error, 503 + { 504 + Ok(parse_component_from_str(&v)) 505 + } 506 + 507 + fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error> 508 + where 509 + A: SeqAccess<'de>, 510 + { 511 + let items = Vec::<Component>::deserialize( 512 + SeqAccessDeserializer::new(seq), 513 + )?; 514 + Ok(expand_legacy(Component::Array(items))) 515 + } 516 + 517 + fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error> 518 + where 519 + A: MapAccess<'de>, 520 + { 521 + let obj = ComponentObject::deserialize( 522 + MapAccessDeserializer::new(map), 523 + )?; 524 + Ok(expand_legacy(Component::Object(Box::new(obj)))) 525 + } 526 + } 527 + 528 + deserializer.deserialize_any(ComponentVisitor) 529 + } 530 + } 531 + 532 + #[cfg(test)] 533 + mod tests { 534 + use super::*; 535 + 536 + #[test] 537 + fn normalize_plain_text() { 538 + let c = parse_component_from_str("hello world"); 539 + assert_eq!(c.normalize(), "hello world"); 540 + } 541 + 542 + #[test] 543 + fn normalize_json_object() { 544 + let c = parse_component_from_str(r#"{"text":"hello §aworld"}"#); 545 + assert_eq!(c.normalize(), "hello world"); 546 + } 547 + 548 + #[test] 549 + fn normalize_json_array() { 550 + let c = 551 + parse_component_from_str(r#"[{"text":"hi "},{"text":"there"}]"#); 552 + assert_eq!(c.normalize(), "hi there"); 553 + } 554 + 555 + #[test] 556 + fn normalize_stringified_json() { 557 + let c = parse_component_from_str(r#""{\"text\":\"hello\"}""#); 558 + assert_eq!(c.normalize(), "hello"); 559 + } 560 + 561 + #[test] 562 + fn legacy_section_colors() { 563 + let c = parse_component_from_str("hi §aworld"); 564 + let text = c.to_plain_text(); 565 + assert_eq!(text, "hi world"); 566 + match c { 567 + Component::Array(items) => { 568 + assert_eq!(items.len(), 2); 569 + } 570 + _ => panic!("expected array component"), 571 + } 572 + } 573 + 574 + #[test] 575 + fn legacy_text_prefix() { 576 + let c = parse_component_from_str("text\t\"hello §aworld\""); 577 + assert_eq!(c.normalize(), "hello world"); 578 + } 579 + }
+76
nara_core/src/lib.rs
··· 1 + use std::{collections::HashMap, path::Path}; 2 + 3 + use ::uuid::Uuid; 4 + use serde::{Deserialize, Serialize}; 5 + use serde_with::{hex::Hex, serde_as}; 6 + 7 + use crate::book::{Book, BookHash}; 8 + 9 + pub mod book; 10 + pub mod color; 11 + pub mod component; 12 + pub mod profile; 13 + pub mod uuid; 14 + 15 + #[serde_as] 16 + #[derive(Debug, Default, Serialize, Deserialize)] 17 + pub struct BookContainer { 18 + #[serde_as(as = "HashMap<Hex, _>")] 19 + books: HashMap<BookHash, Book>, 20 + } 21 + 22 + impl BookContainer { 23 + /// Opens an existing container at `path`, or returns an empty one if the 24 + /// file does not exist yet. 25 + pub fn open_or_new<P>(path: P) -> nara_io::Result<BookContainer> 26 + where 27 + P: AsRef<Path>, 28 + { 29 + if path.as_ref().exists() { 30 + let buffer = std::fs::read(path)?; 31 + nara_io::read_json_gz(&buffer) 32 + } else { 33 + Ok(Self::default()) 34 + } 35 + } 36 + 37 + pub fn read<P>(path: P) -> nara_io::Result<BookContainer> 38 + where 39 + P: AsRef<Path>, 40 + { 41 + let buffer = std::fs::read(path)?; 42 + nara_io::read_json_gz(&buffer) 43 + } 44 + 45 + /// Adds a book. Returns `true` if the book was new, `false` if it was 46 + /// already present (duplicate hash). 47 + pub fn add(&mut self, book: Book) -> bool { 48 + let hash = book.hash(); 49 + if self.books.contains_key(&hash) { 50 + return false; 51 + } 52 + self.books.insert(hash, book); 53 + true 54 + } 55 + 56 + pub fn remove(&mut self, book: Book) { 57 + self.books.remove(&book.hash()); 58 + } 59 + 60 + pub fn books(&self) -> impl Iterator<Item = &Book> { 61 + self.books.values() 62 + } 63 + 64 + pub fn save<P>(&self, path: P) -> nara_io::Result<()> 65 + where 66 + P: AsRef<Path>, 67 + { 68 + let bytes = nara_io::write_json_gz(self)?; 69 + std::fs::write(path, bytes)?; 70 + Ok(()) 71 + } 72 + } 73 + 74 + pub fn i64_pair_to_uuid(most_significant: i64, least_signifcant: i64) -> Uuid { 75 + Uuid::from_u64_pair(most_significant as u64, least_signifcant as u64) 76 + }
+29
nara_core/src/profile.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[serde_with::skip_serializing_none] 4 + #[derive(Debug, Clone, Serialize, Deserialize)] 5 + pub struct PlayerProfile { 6 + name: Option<String>, 7 + id: Option<[i32; 4]>, // TODO(kokiriglade): use an actual UUID type 8 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 9 + properties: Vec<PlayerProfileProperty>, 10 + texture: Option<String>, 11 + cape: Option<String>, 12 + elytra: Option<String>, 13 + model: Option<PlayerModel>, 14 + } 15 + 16 + #[serde_with::skip_serializing_none] 17 + #[derive(Debug, Clone, Serialize, Deserialize)] 18 + pub struct PlayerProfileProperty { 19 + name: String, 20 + value: String, 21 + signature: Option<String>, 22 + } 23 + 24 + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 25 + #[serde(rename_all = "snake_case")] 26 + pub enum PlayerModel { 27 + Slim, 28 + Wide, 29 + }
+67
nara_core/src/uuid.rs
··· 1 + use serde::{Deserialize, Deserializer, Serialize, Serializer}; 2 + use serde_with::{DeserializeAs, SerializeAs}; 3 + 4 + /// Usage: `#[serde_as(as = "UuidIntArray")]` or 5 + /// `#[serde_as(as = "Option<UuidIntArray>")]`. 6 + pub struct UuidIntArray; 7 + 8 + impl SerializeAs<::uuid::Uuid> for UuidIntArray { 9 + fn serialize_as<S>( 10 + uuid: &::uuid::Uuid, 11 + serializer: S, 12 + ) -> Result<S::Ok, S::Error> 13 + where 14 + S: Serializer, 15 + { 16 + let b = uuid.as_bytes(); 17 + let arr: [i32; 4] = [ 18 + i32::from_be_bytes([b[0], b[1], b[2], b[3]]), 19 + i32::from_be_bytes([b[4], b[5], b[6], b[7]]), 20 + i32::from_be_bytes([b[8], b[9], b[10], b[11]]), 21 + i32::from_be_bytes([b[12], b[13], b[14], b[15]]), 22 + ]; 23 + arr.serialize(serializer) 24 + } 25 + } 26 + 27 + impl<'de> DeserializeAs<'de, ::uuid::Uuid> for UuidIntArray { 28 + fn deserialize_as<D>(deserializer: D) -> Result<::uuid::Uuid, D::Error> 29 + where 30 + D: Deserializer<'de>, 31 + { 32 + let [a, b, c, d] = <[i32; 4]>::deserialize(deserializer)?; 33 + let mut bytes = [0u8; 16]; 34 + bytes[0..4].copy_from_slice(&a.to_be_bytes()); 35 + bytes[4..8].copy_from_slice(&b.to_be_bytes()); 36 + bytes[8..12].copy_from_slice(&c.to_be_bytes()); 37 + bytes[12..16].copy_from_slice(&d.to_be_bytes()); 38 + Ok(::uuid::Uuid::from_bytes(bytes)) 39 + } 40 + } 41 + 42 + /// Usage: `#[serde_as(as = "UuidLongPair")]` or 43 + /// `#[serde_as(as = "Option<UuidLongPair>")]`. 44 + pub struct UuidLongPair; 45 + 46 + impl SerializeAs<::uuid::Uuid> for UuidLongPair { 47 + fn serialize_as<S>( 48 + uuid: &::uuid::Uuid, 49 + serializer: S, 50 + ) -> Result<S::Ok, S::Error> 51 + where 52 + S: Serializer, 53 + { 54 + let (msb, lsb) = uuid.as_u64_pair(); 55 + [msb as i64, lsb as i64].serialize(serializer) 56 + } 57 + } 58 + 59 + impl<'de> DeserializeAs<'de, ::uuid::Uuid> for UuidLongPair { 60 + fn deserialize_as<D>(deserializer: D) -> Result<::uuid::Uuid, D::Error> 61 + where 62 + D: Deserializer<'de>, 63 + { 64 + let [msb, lsb] = <[i64; 2]>::deserialize(deserializer)?; 65 + Ok(::uuid::Uuid::from_u64_pair(msb as u64, lsb as u64)) 66 + } 67 + }
+3
nara_core/tests/fixtures/keybind.json
··· 1 + { 2 + "keybind": "key.inventory" 3 + }
+7
nara_core/tests/fixtures/nbt_block.json
··· 1 + { 2 + "source": "block", 3 + "nbt": "foo", 4 + "interpret": true, 5 + "separator": ", ", 6 + "block": "~ ~ ~" 7 + }
+7
nara_core/tests/fixtures/nbt_entity.json
··· 1 + { 2 + "source": "entity", 3 + "nbt": "foo", 4 + "interpret": true, 5 + "separator": ", ", 6 + "entity": "@p" 7 + }
+7
nara_core/tests/fixtures/nbt_storage.json
··· 1 + { 2 + "source": "storage", 3 + "nbt": "foo", 4 + "interpret": true, 5 + "separator": ", ", 6 + "storage": "minecraft:example" 7 + }
+5
nara_core/tests/fixtures/object_atlas.json
··· 1 + { 2 + "object": "atlas", 3 + "atlas": "minecraft:blocks", 4 + "sprite": "minecraft:stone" 5 + }
+4
nara_core/tests/fixtures/object_player.json
··· 1 + { 2 + "object": "player", 3 + "player": "kokiriglade" 4 + }
+18
nara_core/tests/fixtures/object_player_profile.json
··· 1 + { 2 + "object": "player", 3 + "player": { 4 + "name": "kokiriglade", 5 + "id": [452952, 452952, 459252, 459252], 6 + "properties": [ 7 + { 8 + "name": "textures", 9 + "value": "foo", 10 + "signature": "bar" 11 + } 12 + ], 13 + "texture": "minecraft:steve", 14 + "cape": "minecraft:some_cape", 15 + "model": "wide" 16 + }, 17 + "hat": false 18 + }
+6
nara_core/tests/fixtures/score.json
··· 1 + { 2 + "score": { 3 + "name": "@p", 4 + "objective": "foo.bar" 5 + } 6 + }
+4
nara_core/tests/fixtures/selector.json
··· 1 + { 2 + "selector": "@p", 3 + "separator": ", " 4 + }
+1
nara_core/tests/fixtures/text_plain.json
··· 1 + "Hello, World!"
+3
nara_core/tests/fixtures/text_tagged.json
··· 1 + { 2 + "text": "Hello, World!" 3 + }
+10
nara_core/tests/fixtures/translation.json
··· 1 + { 2 + "translate": "foo.bar", 3 + "fallback": "foo bar xyzzy", 4 + "with": [ 5 + "foo", 6 + { 7 + "text": "bar" 8 + } 9 + ] 10 + }
+9
nara_core/tests/keybind.rs
··· 1 + use nara_core::component::Component; 2 + 3 + const FIXTURE: &str = include_str!("fixtures/keybind.json"); 4 + 5 + #[test] 6 + fn keybind() { 7 + let parsed = serde_json::from_str::<Component>(FIXTURE).unwrap(); 8 + insta::assert_debug_snapshot!(parsed); 9 + }
+23
nara_core/tests/nbt.rs
··· 1 + use nara_core::component::Component; 2 + 3 + const BLOCK_FIXTURE: &str = include_str!("fixtures/nbt_block.json"); 4 + const ENTITY_FIXTURE: &str = include_str!("fixtures/nbt_entity.json"); 5 + const STORAGE_FIXTURE: &str = include_str!("fixtures/nbt_storage.json"); 6 + 7 + #[test] 8 + fn block() { 9 + let parsed = serde_json::from_str::<Component>(BLOCK_FIXTURE).unwrap(); 10 + insta::assert_debug_snapshot!(parsed); 11 + } 12 + 13 + #[test] 14 + fn entity() { 15 + let parsed = serde_json::from_str::<Component>(ENTITY_FIXTURE).unwrap(); 16 + insta::assert_debug_snapshot!(parsed); 17 + } 18 + 19 + #[test] 20 + fn storage() { 21 + let parsed = serde_json::from_str::<Component>(STORAGE_FIXTURE).unwrap(); 22 + insta::assert_debug_snapshot!(parsed); 23 + }
+25
nara_core/tests/object.rs
··· 1 + use nara_core::component::Component; 2 + 3 + const ATLAS_FIXTURE: &str = include_str!("fixtures/object_atlas.json"); 4 + const PLAYER_FIXTURE: &str = include_str!("fixtures/object_player.json"); 5 + const PLAYER_PROFILE_FIXTURE: &str = 6 + include_str!("fixtures/object_player_profile.json"); 7 + 8 + #[test] 9 + fn atlas() { 10 + let parsed = serde_json::from_str::<Component>(ATLAS_FIXTURE).unwrap(); 11 + insta::assert_debug_snapshot!(parsed); 12 + } 13 + 14 + #[test] 15 + fn player() { 16 + let parsed = serde_json::from_str::<Component>(PLAYER_FIXTURE).unwrap(); 17 + insta::assert_debug_snapshot!(parsed); 18 + } 19 + 20 + #[test] 21 + fn player_profile() { 22 + let parsed = 23 + serde_json::from_str::<Component>(PLAYER_PROFILE_FIXTURE).unwrap(); 24 + insta::assert_debug_snapshot!(parsed); 25 + }
+9
nara_core/tests/score.rs
··· 1 + use nara_core::component::Component; 2 + 3 + const FIXTURE: &str = include_str!("fixtures/score.json"); 4 + 5 + #[test] 6 + fn score() { 7 + let parsed = serde_json::from_str::<Component>(FIXTURE).unwrap(); 8 + insta::assert_debug_snapshot!(parsed); 9 + }
+9
nara_core/tests/selector.rs
··· 1 + use nara_core::component::Component; 2 + 3 + const FIXTURE: &str = include_str!("fixtures/selector.json"); 4 + 5 + #[test] 6 + fn selector() { 7 + let parsed = serde_json::from_str::<Component>(FIXTURE).unwrap(); 8 + insta::assert_debug_snapshot!(parsed); 9 + }
+29
nara_core/tests/snapshots/keybind__keybind.snap
··· 1 + --- 2 + source: nara_core/tests/keybind.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: None, 8 + translation: None, 9 + score: None, 10 + selector: None, 11 + keybind: Some( 12 + KeybindComponent { 13 + keybind: "key.inventory", 14 + }, 15 + ), 16 + nbt: None, 17 + object: None, 18 + formatting: ComponentFormatting { 19 + color: None, 20 + font: None, 21 + bold: None, 22 + italic: None, 23 + underlined: None, 24 + strikethrough: None, 25 + obfuscated: None, 26 + }, 27 + children: [], 28 + }, 29 + )
+41
nara_core/tests/snapshots/nbt__block.snap
··· 1 + --- 2 + source: nara_core/tests/nbt.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: None, 8 + translation: None, 9 + score: None, 10 + selector: None, 11 + keybind: None, 12 + nbt: Some( 13 + NbtComponent { 14 + source: Some( 15 + Block, 16 + ), 17 + path: "foo", 18 + interpret: Some( 19 + true, 20 + ), 21 + separator: None, 22 + entity: None, 23 + block: Some( 24 + "~ ~ ~", 25 + ), 26 + storage: None, 27 + }, 28 + ), 29 + object: None, 30 + formatting: ComponentFormatting { 31 + color: None, 32 + font: None, 33 + bold: None, 34 + italic: None, 35 + underlined: None, 36 + strikethrough: None, 37 + obfuscated: None, 38 + }, 39 + children: [], 40 + }, 41 + )
+41
nara_core/tests/snapshots/nbt__entity.snap
··· 1 + --- 2 + source: nara_core/tests/nbt.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: None, 8 + translation: None, 9 + score: None, 10 + selector: None, 11 + keybind: None, 12 + nbt: Some( 13 + NbtComponent { 14 + source: Some( 15 + Entity, 16 + ), 17 + path: "foo", 18 + interpret: Some( 19 + true, 20 + ), 21 + separator: None, 22 + entity: Some( 23 + "@p", 24 + ), 25 + block: None, 26 + storage: None, 27 + }, 28 + ), 29 + object: None, 30 + formatting: ComponentFormatting { 31 + color: None, 32 + font: None, 33 + bold: None, 34 + italic: None, 35 + underlined: None, 36 + strikethrough: None, 37 + obfuscated: None, 38 + }, 39 + children: [], 40 + }, 41 + )
+41
nara_core/tests/snapshots/nbt__storage.snap
··· 1 + --- 2 + source: nara_core/tests/nbt.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: None, 8 + translation: None, 9 + score: None, 10 + selector: None, 11 + keybind: None, 12 + nbt: Some( 13 + NbtComponent { 14 + source: Some( 15 + Storage, 16 + ), 17 + path: "foo", 18 + interpret: Some( 19 + true, 20 + ), 21 + separator: None, 22 + entity: None, 23 + block: None, 24 + storage: Some( 25 + "minecraft:example", 26 + ), 27 + }, 28 + ), 29 + object: None, 30 + formatting: ComponentFormatting { 31 + color: None, 32 + font: None, 33 + bold: None, 34 + italic: None, 35 + underlined: None, 36 + strikethrough: None, 37 + obfuscated: None, 38 + }, 39 + children: [], 40 + }, 41 + )
+34
nara_core/tests/snapshots/object__atlas.snap
··· 1 + --- 2 + source: nara_core/tests/object.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: None, 8 + translation: None, 9 + score: None, 10 + selector: None, 11 + keybind: None, 12 + nbt: None, 13 + object: Some( 14 + Atlas( 15 + AtlasObject { 16 + atlas: Some( 17 + "minecraft:blocks", 18 + ), 19 + sprite: "minecraft:stone", 20 + }, 21 + ), 22 + ), 23 + formatting: ComponentFormatting { 24 + color: None, 25 + font: None, 26 + bold: None, 27 + italic: None, 28 + underlined: None, 29 + strikethrough: None, 30 + obfuscated: None, 31 + }, 32 + children: [], 33 + }, 34 + )
+34
nara_core/tests/snapshots/object__player.snap
··· 1 + --- 2 + source: nara_core/tests/object.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: None, 8 + translation: None, 9 + score: None, 10 + selector: None, 11 + keybind: None, 12 + nbt: None, 13 + object: Some( 14 + Player( 15 + PlayerObject { 16 + player: Name( 17 + "kokiriglade", 18 + ), 19 + hat: None, 20 + }, 21 + ), 22 + ), 23 + formatting: ComponentFormatting { 24 + color: None, 25 + font: None, 26 + bold: None, 27 + italic: None, 28 + underlined: None, 29 + strikethrough: None, 30 + obfuscated: None, 31 + }, 32 + children: [], 33 + }, 34 + )
+67
nara_core/tests/snapshots/object__player_profile.snap
··· 1 + --- 2 + source: nara_core/tests/object.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: None, 8 + translation: None, 9 + score: None, 10 + selector: None, 11 + keybind: None, 12 + nbt: None, 13 + object: Some( 14 + Player( 15 + PlayerObject { 16 + player: Profile( 17 + PlayerProfile { 18 + name: Some( 19 + "kokiriglade", 20 + ), 21 + id: Some( 22 + [ 23 + 452952, 24 + 452952, 25 + 459252, 26 + 459252, 27 + ], 28 + ), 29 + properties: [ 30 + PlayerProfileProperty { 31 + name: "textures", 32 + value: "foo", 33 + signature: Some( 34 + "bar", 35 + ), 36 + }, 37 + ], 38 + texture: Some( 39 + "minecraft:steve", 40 + ), 41 + cape: Some( 42 + "minecraft:some_cape", 43 + ), 44 + elytra: None, 45 + model: Some( 46 + Wide, 47 + ), 48 + }, 49 + ), 50 + hat: Some( 51 + false, 52 + ), 53 + }, 54 + ), 55 + ), 56 + formatting: ComponentFormatting { 57 + color: None, 58 + font: None, 59 + bold: None, 60 + italic: None, 61 + underlined: None, 62 + strikethrough: None, 63 + obfuscated: None, 64 + }, 65 + children: [], 66 + }, 67 + )
+30
nara_core/tests/snapshots/score__score.snap
··· 1 + --- 2 + source: nara_core/tests/score.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: None, 8 + translation: None, 9 + score: Some( 10 + ScoreComponent { 11 + name: "@p", 12 + objective: "foo.bar", 13 + }, 14 + ), 15 + selector: None, 16 + keybind: None, 17 + nbt: None, 18 + object: None, 19 + formatting: ComponentFormatting { 20 + color: None, 21 + font: None, 22 + bold: None, 23 + italic: None, 24 + underlined: None, 25 + strikethrough: None, 26 + obfuscated: None, 27 + }, 28 + children: [], 29 + }, 30 + )
+34
nara_core/tests/snapshots/selector__selector.snap
··· 1 + --- 2 + source: nara_core/tests/selector.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: None, 8 + translation: None, 9 + score: None, 10 + selector: Some( 11 + SelectorComponent { 12 + selector: "@p", 13 + separator: Some( 14 + String( 15 + ", ", 16 + ), 17 + ), 18 + }, 19 + ), 20 + keybind: None, 21 + nbt: None, 22 + object: None, 23 + formatting: ComponentFormatting { 24 + color: None, 25 + font: None, 26 + bold: None, 27 + italic: None, 28 + underlined: None, 29 + strikethrough: None, 30 + obfuscated: None, 31 + }, 32 + children: [], 33 + }, 34 + )
+7
nara_core/tests/snapshots/text__plain.snap
··· 1 + --- 2 + source: nara_core/tests/text.rs 3 + expression: parsed 4 + --- 5 + String( 6 + "Hello, World!", 7 + )
+29
nara_core/tests/snapshots/text__tagged.snap
··· 1 + --- 2 + source: nara_core/tests/text.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: Some( 8 + TextComponent { 9 + text: "Hello, World!", 10 + }, 11 + ), 12 + translation: None, 13 + score: None, 14 + selector: None, 15 + keybind: None, 16 + nbt: None, 17 + object: None, 18 + formatting: ComponentFormatting { 19 + color: None, 20 + font: None, 21 + bold: None, 22 + italic: None, 23 + underlined: None, 24 + strikethrough: None, 25 + obfuscated: None, 26 + }, 27 + children: [], 28 + }, 29 + )
+62
nara_core/tests/snapshots/translation__translation.snap
··· 1 + --- 2 + source: nara_core/tests/translation.rs 3 + expression: parsed 4 + --- 5 + Object( 6 + ComponentObject { 7 + text: None, 8 + translation: Some( 9 + TranslationComponent { 10 + translate: "foo.bar", 11 + fallback: Some( 12 + "foo bar xyzzy", 13 + ), 14 + with: [ 15 + String( 16 + "foo", 17 + ), 18 + Object( 19 + ComponentObject { 20 + text: Some( 21 + TextComponent { 22 + text: "bar", 23 + }, 24 + ), 25 + translation: None, 26 + score: None, 27 + selector: None, 28 + keybind: None, 29 + nbt: None, 30 + object: None, 31 + formatting: ComponentFormatting { 32 + color: None, 33 + font: None, 34 + bold: None, 35 + italic: None, 36 + underlined: None, 37 + strikethrough: None, 38 + obfuscated: None, 39 + }, 40 + children: [], 41 + }, 42 + ), 43 + ], 44 + }, 45 + ), 46 + score: None, 47 + selector: None, 48 + keybind: None, 49 + nbt: None, 50 + object: None, 51 + formatting: ComponentFormatting { 52 + color: None, 53 + font: None, 54 + bold: None, 55 + italic: None, 56 + underlined: None, 57 + strikethrough: None, 58 + obfuscated: None, 59 + }, 60 + children: [], 61 + }, 62 + )
+16
nara_core/tests/text.rs
··· 1 + use nara_core::component::Component; 2 + 3 + const PLAIN_FIXTURE: &str = include_str!("fixtures/text_plain.json"); 4 + const TAGGED_FIXTURE: &str = include_str!("fixtures/text_tagged.json"); 5 + 6 + #[test] 7 + fn plain() { 8 + let parsed = serde_json::from_str::<Component>(PLAIN_FIXTURE).unwrap(); 9 + insta::assert_debug_snapshot!(parsed); 10 + } 11 + 12 + #[test] 13 + fn tagged() { 14 + let parsed = serde_json::from_str::<Component>(TAGGED_FIXTURE).unwrap(); 15 + insta::assert_debug_snapshot!(parsed); 16 + }
+9
nara_core/tests/translation.rs
··· 1 + use nara_core::component::Component; 2 + 3 + const FIXTURE: &str = include_str!("fixtures/translation.json"); 4 + 5 + #[test] 6 + fn translation() { 7 + let parsed = serde_json::from_str::<Component>(FIXTURE).unwrap(); 8 + insta::assert_debug_snapshot!(parsed); 9 + }
+11
nara_io/Cargo.toml
··· 1 + [package] 2 + name = "nara_io" 3 + version.workspace = true 4 + edition.workspace = true 5 + 6 + [dependencies] 7 + crab_nbt.workspace = true 8 + flate2.workspace = true 9 + serde.workspace = true 10 + serde_json.workspace = true 11 + thiserror.workspace = true
+86
nara_io/src/lib.rs
··· 1 + use flate2::{ 2 + Compression, 3 + read::{GzDecoder, ZlibDecoder}, 4 + write::GzEncoder, 5 + }; 6 + use serde::{Deserialize, Serialize}; 7 + use std::io::{Cursor, Read, Write}; 8 + 9 + #[derive(Debug, thiserror::Error)] 10 + pub enum Error { 11 + #[error(transparent)] 12 + Io(#[from] std::io::Error), 13 + #[error(transparent)] 14 + Nbt(#[from] crab_nbt::error::Error), 15 + #[error(transparent)] 16 + Json(#[from] serde_json::Error), 17 + } 18 + 19 + pub type Result<T> = std::result::Result<T, Error>; 20 + 21 + pub fn read_nbt<T>(src: &[u8]) -> Result<T> 22 + where 23 + T: for<'de> Deserialize<'de>, 24 + { 25 + let decomp = maybe_decompress(src)?; 26 + let mut cur = Cursor::new(decomp.as_slice()); 27 + 28 + Ok(crab_nbt::serde::de::from_cursor::<T>(&mut cur)?) 29 + } 30 + 31 + pub fn write_nbt<T>(value: &T) -> Result<Vec<u8>> 32 + where 33 + T: Serialize, 34 + { 35 + let nbt_bytes = crab_nbt::serde::ser::to_bytes_unnamed(value)?; 36 + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 37 + encoder.write_all(&nbt_bytes)?; 38 + Ok(encoder.finish()?) 39 + } 40 + 41 + pub fn write_json_gz<T>(value: &T) -> Result<Vec<u8>> 42 + where 43 + T: Serialize, 44 + { 45 + let json = serde_json::to_vec(value)?; 46 + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 47 + encoder.write_all(&json)?; 48 + Ok(encoder.finish()?) 49 + } 50 + 51 + pub fn read_json_gz<T>(src: &[u8]) -> Result<T> 52 + where 53 + T: for<'de> Deserialize<'de>, 54 + { 55 + let decompressed = maybe_decompress(src)?; 56 + Ok(serde_json::from_slice(&decompressed)?) 57 + } 58 + 59 + pub fn maybe_decompress(src: &[u8]) -> Result<Vec<u8>> { 60 + let mut src_cursor = Cursor::new(src); 61 + 62 + src_cursor.set_position(0); 63 + let mut header = [0u8; 2]; 64 + src_cursor.read_exact(&mut header)?; 65 + 66 + let mut decoded = Vec::new(); 67 + match &header { 68 + [0x1f, 0x8b] => { 69 + src_cursor.set_position(0); 70 + GzDecoder::new(src_cursor).read_to_end(&mut decoded)?; 71 + } 72 + [0x78, _b @ 0x01..=0xda] 73 + if (u16::from(header[0]) * 256 + u16::from(header[1])) % 31 74 + == 0 => 75 + { 76 + src_cursor.set_position(0); 77 + ZlibDecoder::new(src_cursor).read_to_end(&mut decoded)?; 78 + } 79 + _ => { 80 + src_cursor.set_position(0); 81 + src_cursor.read_to_end(&mut decoded)?; 82 + } 83 + } 84 + 85 + Ok(decoded) 86 + }
+10
nara_mcr/Cargo.toml
··· 1 + [package] 2 + name = "nara_mcr" 3 + version.workspace = true 4 + edition.workspace = true 5 + 6 + [dependencies] 7 + crab_nbt.workspace = true 8 + flate2.workspace = true 9 + serde.workspace = true 10 + thiserror.workspace = true
+131
nara_mcr/src/lib.rs
··· 1 + use std::io::{Cursor, Read}; 2 + 3 + use flate2::read::{GzDecoder, ZlibDecoder}; 4 + use serde::de::DeserializeOwned; 5 + 6 + const SECTOR_BYTES: usize = 4096; 7 + const REGION_SIDE: usize = 32; 8 + const CHUNK_COUNT: usize = REGION_SIDE * REGION_SIDE; 9 + const HEADER_BYTES: usize = 2 * SECTOR_BYTES; 10 + 11 + #[derive(Debug, thiserror::Error)] 12 + pub enum Error { 13 + #[error( 14 + "region data too short: need at least {HEADER_BYTES} bytes, got {0}" 15 + )] 16 + TooShort(usize), 17 + #[error( 18 + "chunk ({0}, {1}) references sector offset {2} which is outside the file" 19 + )] 20 + ChunkOutOfBounds(u8, u8, usize), 21 + #[error("unknown compression type {0} for chunk ({1}, {2})")] 22 + UnknownCompression(u8, u8, u8), 23 + #[error(transparent)] 24 + Io(#[from] std::io::Error), 25 + #[error(transparent)] 26 + Nbt(#[from] crab_nbt::error::Error), 27 + } 28 + 29 + pub type Result<T> = std::result::Result<T, Error>; 30 + 31 + #[derive(Debug)] 32 + pub struct McRegion<C> { 33 + chunks: Vec<Option<C>>, 34 + } 35 + 36 + impl<C: DeserializeOwned> McRegion<C> { 37 + pub fn from_bytes(data: &[u8]) -> Result<Self> { 38 + if data.len() < HEADER_BYTES { 39 + return Err(Error::TooShort(data.len())); 40 + } 41 + 42 + let mut chunks: Vec<Option<C>> = 43 + (0..CHUNK_COUNT).map(|_| None).collect(); 44 + 45 + for (i, chunk) in chunks.iter_mut().enumerate().take(CHUNK_COUNT) { 46 + let x = (i % REGION_SIDE) as u8; 47 + let z = (i / REGION_SIDE) as u8; 48 + 49 + // location table: first 4096 bytes, 4 bytes per entry. 50 + // [offset_hi, offset_mid, offset_lo, sector_count] 51 + // offset is in units of 4096-byte sectors from the file start. 52 + let loc = i * 4; 53 + let offset_sectors = u32::from_be_bytes([ 54 + 0, 55 + data[loc], 56 + data[loc + 1], 57 + data[loc + 2], 58 + ]) as usize; 59 + 60 + if offset_sectors == 0 { 61 + continue; 62 + } 63 + 64 + let byte_offset = offset_sectors * SECTOR_BYTES; 65 + if byte_offset + 5 > data.len() { 66 + return Err(Error::ChunkOutOfBounds(x, z, offset_sectors)); 67 + } 68 + 69 + let length = u32::from_be_bytes([ 70 + data[byte_offset], 71 + data[byte_offset + 1], 72 + data[byte_offset + 2], 73 + data[byte_offset + 3], 74 + ]) as usize; 75 + 76 + if length == 0 || byte_offset + 4 + length > data.len() { 77 + return Err(Error::ChunkOutOfBounds(x, z, offset_sectors)); 78 + } 79 + 80 + let compression = data[byte_offset + 4]; 81 + let compressed = &data[byte_offset + 5..byte_offset + 4 + length]; 82 + 83 + let decompressed = decompress(compressed, compression, x, z)?; 84 + let mut cursor = Cursor::new(decompressed.as_slice()); 85 + *chunk = Some(crab_nbt::serde::de::from_cursor::<C>(&mut cursor)?); 86 + } 87 + 88 + Ok(Self { chunks }) 89 + } 90 + 91 + pub fn chunk(&self, local_x: u8, local_z: u8) -> Option<&C> { 92 + if local_x as usize >= REGION_SIDE || local_z as usize >= REGION_SIDE { 93 + return None; 94 + } 95 + self.chunks[local_z as usize * REGION_SIDE + local_x as usize].as_ref() 96 + } 97 + 98 + pub fn iter(&self) -> impl Iterator<Item = ((u8, u8), &C)> { 99 + self.chunks.iter().enumerate().filter_map(|(i, slot)| { 100 + slot.as_ref().map(|c| { 101 + let x = (i % REGION_SIDE) as u8; 102 + let z = (i / REGION_SIDE) as u8; 103 + ((x, z), c) 104 + }) 105 + }) 106 + } 107 + 108 + pub fn into_iter_chunks(self) -> impl Iterator<Item = ((u8, u8), C)> { 109 + self.chunks.into_iter().enumerate().filter_map(|(i, slot)| { 110 + slot.map(|c| { 111 + let x = (i % REGION_SIDE) as u8; 112 + let z = (i / REGION_SIDE) as u8; 113 + ((x, z), c) 114 + }) 115 + }) 116 + } 117 + } 118 + 119 + fn decompress(data: &[u8], compression: u8, x: u8, z: u8) -> Result<Vec<u8>> { 120 + let mut buf = Vec::new(); 121 + match compression { 122 + 1 => GzDecoder::new(data).read_to_end(&mut buf)?, 123 + 2 => ZlibDecoder::new(data).read_to_end(&mut buf)?, 124 + 3 => { 125 + buf.extend_from_slice(data); 126 + buf.len() 127 + } 128 + other => return Err(Error::UnknownCompression(other, x, z)), 129 + }; 130 + Ok(buf) 131 + }
nara_mcr/tests/fixtures/r.0.0.mca

This is a binary file and will not be displayed.

+11
nara_slurper_1_12_core/Cargo.toml
··· 1 + [package] 2 + name = "nara_slurper_1_12_core" 3 + version.workspace = true 4 + edition.workspace = true 5 + 6 + [dependencies] 7 + nara_core.workspace = true 8 + serde.workspace = true 9 + 10 + [dev-dependencies] 11 + nara_mcr.workspace = true
+3
nara_slurper_1_12_core/README.md
··· 1 + # nara_slurper_1_12_core 2 + 3 + Shared code for 1.12.2 version handling.
+70
nara_slurper_1_12_core/src/chunk.rs
··· 1 + use serde::Deserialize; 2 + 3 + use crate::item::{InventoryItemStack, ItemStack}; 4 + 5 + /// A 1.12.2 Anvil chunk, containing only the fields we care about. 6 + /// Unknown fields are silently ignored by serde. 7 + #[derive(Debug, Deserialize)] 8 + pub struct Chunk { 9 + #[serde(rename = "DataVersion")] 10 + pub data_version: i32, 11 + #[serde(rename = "Level")] 12 + pub level: ChunkLevel, 13 + } 14 + 15 + #[derive(Debug, Deserialize)] 16 + pub struct ChunkLevel { 17 + #[serde(rename = "xPos")] 18 + pub x_pos: i32, 19 + #[serde(rename = "zPos")] 20 + pub z_pos: i32, 21 + #[serde(rename = "TileEntities", default)] 22 + pub tile_entities: Vec<TileEntity>, 23 + #[serde(rename = "Entities", default)] 24 + pub entities: Vec<Entity>, 25 + } 26 + 27 + #[derive(Debug, Deserialize)] 28 + #[serde(untagged)] 29 + pub enum Entity { 30 + ItemHolder(ItemHolderEntity), 31 + Inventoried(InventoriedEntity), 32 + Base(BaseEntity), 33 + } 34 + 35 + #[derive(Debug, Deserialize)] 36 + pub struct BaseEntity { 37 + #[serde(rename = "Pos")] 38 + pub position: [f64; 3], 39 + pub id: String, 40 + } 41 + 42 + #[derive(Debug, Deserialize)] 43 + pub struct ItemHolderEntity { 44 + #[serde(flatten)] 45 + pub base: BaseEntity, 46 + #[serde(rename = "Item")] 47 + pub item: ItemStack, 48 + } 49 + 50 + #[derive(Debug, Deserialize)] 51 + pub struct InventoriedEntity { 52 + #[serde(flatten)] 53 + pub base: BaseEntity, 54 + #[serde(rename = "Items")] 55 + pub items: Vec<InventoryItemStack>, 56 + } 57 + 58 + /// A placed block entity (chest, furnace, shulker box, …). 59 + /// `Items` is present on container block entities and absent on others, 60 + /// so it defaults to an empty vec. 61 + #[derive(Debug, Deserialize)] 62 + pub struct TileEntity { 63 + pub id: String, 64 + pub x: i32, 65 + pub y: i32, 66 + pub z: i32, 67 + #[serde(rename = "Items", default)] 68 + // TODO(kokiriglade): not every tile entity has items, but its probably ok to assume they do 69 + pub items: Vec<InventoryItemStack>, 70 + }
+275
nara_slurper_1_12_core/src/item.rs
··· 1 + use nara_core::{ 2 + book::BookContent, 3 + component::{Component, parse_component_from_str}, 4 + }; 5 + use serde::{Deserialize, Deserializer}; 6 + 7 + #[derive(Debug, Clone)] 8 + pub enum ItemStack { 9 + WrittenBook(WrittenBookStack), 10 + BlockEntity(BlockEntityStack), 11 + Base(BaseItemStack), 12 + } 13 + 14 + #[derive(Debug, Clone)] 15 + pub struct BaseItemStack { 16 + pub count: i8, 17 + pub damage: i16, 18 + pub id: String, 19 + } 20 + 21 + #[derive(Debug, Clone, Deserialize)] 22 + #[serde(rename_all = "PascalCase")] 23 + pub struct DisplayTag { 24 + pub name: Option<String>, 25 + } 26 + 27 + #[derive(Debug, Clone)] 28 + pub struct BaseTag { 29 + pub display: Option<DisplayTag>, 30 + } 31 + 32 + #[derive(Debug, Clone)] 33 + pub struct WrittenBookStack { 34 + pub count: i8, 35 + pub damage: i16, 36 + pub id: String, 37 + pub tag: WrittenBookTag, 38 + } 39 + 40 + #[derive(Debug, Clone)] 41 + pub struct WrittenBookTag { 42 + pub base: BaseTag, 43 + pub author: String, 44 + pub title: String, 45 + pub generation: BookGeneration, 46 + pub resolved: bool, 47 + pub pages: Vec<Component>, 48 + } 49 + 50 + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] 51 + pub enum BookGeneration { 52 + #[default] 53 + Original, 54 + CopyOfOriginal, 55 + CopyOfCopy, 56 + Tattered, 57 + } 58 + 59 + impl TryFrom<i32> for BookGeneration { 60 + type Error = i32; 61 + 62 + fn try_from(value: i32) -> Result<Self, Self::Error> { 63 + match value { 64 + 0 => Ok(Self::Original), 65 + 1 => Ok(Self::CopyOfOriginal), 66 + 2 => Ok(Self::CopyOfCopy), 67 + 3 => Ok(Self::Tattered), 68 + other => Err(other), 69 + } 70 + } 71 + } 72 + 73 + #[derive(Debug, Clone)] 74 + pub struct BlockEntityStack { 75 + pub count: i8, 76 + pub damage: i16, 77 + pub id: String, 78 + pub tag: BlockEntityTag, 79 + } 80 + 81 + #[derive(Debug, Clone)] 82 + pub struct BlockEntityTag { 83 + pub base: BaseTag, 84 + pub block_entity: BlockEntity, 85 + } 86 + 87 + #[derive(Debug, Clone, Deserialize)] 88 + #[serde(rename_all = "PascalCase")] 89 + pub struct BlockEntity { 90 + #[serde(default)] 91 + pub items: Vec<InventoryItemStack>, 92 + } 93 + 94 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 95 + pub enum BlockEntityKind { 96 + ShulkerBox, 97 + Chest, 98 + TrappedChest, 99 + Hopper, 100 + Dropper, 101 + Dispenser, 102 + Barrel, 103 + Other, 104 + } 105 + 106 + impl BlockEntityStack { 107 + pub fn kind(&self) -> BlockEntityKind { 108 + match self.id.as_str() { 109 + id if id.contains("shulker_box") => BlockEntityKind::ShulkerBox, 110 + "minecraft:chest" => BlockEntityKind::Chest, 111 + "minecraft:trapped_chest" => BlockEntityKind::TrappedChest, 112 + "minecraft:hopper" => BlockEntityKind::Hopper, 113 + "minecraft:dropper" => BlockEntityKind::Dropper, 114 + "minecraft:dispenser" => BlockEntityKind::Dispenser, 115 + _ => BlockEntityKind::Other, 116 + } 117 + } 118 + } 119 + 120 + #[derive(Debug, Clone)] 121 + pub struct InventoryItemStack { 122 + pub item: ItemStack, 123 + pub slot: i8, 124 + } 125 + 126 + #[derive(Deserialize)] 127 + struct RawItemStack { 128 + #[serde(rename = "id")] 129 + id: String, 130 + #[serde(rename = "Count")] 131 + count: i8, 132 + #[serde(rename = "Damage")] 133 + damage: i16, 134 + #[serde(default)] 135 + tag: Option<RawTag>, 136 + } 137 + 138 + #[derive(Deserialize)] 139 + struct RawInventoryItemStack { 140 + #[serde(rename = "id")] 141 + id: String, 142 + #[serde(rename = "Count")] 143 + count: i8, 144 + #[serde(rename = "Damage")] 145 + damage: i16, 146 + #[serde(rename = "Slot")] 147 + slot: i8, 148 + #[serde(default)] 149 + tag: Option<RawTag>, 150 + } 151 + 152 + #[derive(Deserialize, Default)] 153 + struct RawTag { 154 + #[serde(default)] 155 + author: Option<String>, 156 + #[serde(default)] 157 + title: Option<String>, 158 + #[serde(default)] 159 + pages: Option<Vec<String>>, 160 + #[serde(default)] 161 + generation: Option<i32>, 162 + #[serde(default)] 163 + resolved: Option<i8>, 164 + 165 + #[serde(default, rename = "BlockEntityTag")] 166 + block_entity: Option<BlockEntity>, 167 + 168 + #[serde(default)] 169 + display: Option<DisplayTag>, 170 + } 171 + 172 + fn build_item_stack( 173 + id: String, 174 + count: i8, 175 + damage: i16, 176 + tag: Option<RawTag>, 177 + ) -> ItemStack { 178 + if id == "minecraft:written_book" 179 + && let Some(ref t) = tag 180 + && let (Some(author), Some(title), Some(pages)) = 181 + (t.author.clone(), t.title.clone(), t.pages.clone()) 182 + { 183 + let generation = t 184 + .generation 185 + .and_then(|g| BookGeneration::try_from(g).ok()) 186 + .unwrap_or_default(); 187 + let resolved = t.resolved.map(|b| b != 0).unwrap_or(false); 188 + let display = t.display.clone(); 189 + return ItemStack::WrittenBook(WrittenBookStack { 190 + count, 191 + damage, 192 + id, 193 + tag: WrittenBookTag { 194 + base: BaseTag { display }, 195 + author, 196 + title, 197 + generation, 198 + resolved, 199 + pages: pages 200 + .into_iter() 201 + .map(|s| parse_component_from_str(&s)) 202 + .collect(), 203 + }, 204 + }); 205 + } 206 + 207 + if let Some(ref t) = tag 208 + && let Some(ref be) = t.block_entity 209 + { 210 + let display = t.display.clone(); 211 + return ItemStack::BlockEntity(BlockEntityStack { 212 + count, 213 + damage, 214 + id, 215 + tag: BlockEntityTag { 216 + base: BaseTag { display }, 217 + block_entity: be.clone(), 218 + }, 219 + }); 220 + } 221 + 222 + ItemStack::Base(BaseItemStack { count, damage, id }) 223 + } 224 + 225 + impl<'de> Deserialize<'de> for ItemStack { 226 + fn deserialize<D: Deserializer<'de>>( 227 + deserializer: D, 228 + ) -> Result<Self, D::Error> { 229 + let raw = RawItemStack::deserialize(deserializer)?; 230 + Ok(build_item_stack(raw.id, raw.count, raw.damage, raw.tag)) 231 + } 232 + } 233 + 234 + impl<'de> Deserialize<'de> for InventoryItemStack { 235 + fn deserialize<D: Deserializer<'de>>( 236 + deserializer: D, 237 + ) -> Result<Self, D::Error> { 238 + let raw = RawInventoryItemStack::deserialize(deserializer)?; 239 + Ok(InventoryItemStack { 240 + item: build_item_stack(raw.id, raw.count, raw.damage, raw.tag), 241 + slot: raw.slot, 242 + }) 243 + } 244 + } 245 + 246 + impl From<BookGeneration> for nara_core::book::BookGeneration { 247 + fn from(value: BookGeneration) -> Self { 248 + match value { 249 + BookGeneration::Original => { 250 + nara_core::book::BookGeneration::Original 251 + } 252 + BookGeneration::CopyOfOriginal => { 253 + nara_core::book::BookGeneration::CopyOfOriginal 254 + } 255 + BookGeneration::CopyOfCopy => { 256 + nara_core::book::BookGeneration::CopyOfCopy 257 + } 258 + BookGeneration::Tattered => { 259 + nara_core::book::BookGeneration::Tattered 260 + } 261 + } 262 + } 263 + } 264 + 265 + impl From<WrittenBookStack> for BookContent { 266 + fn from(value: WrittenBookStack) -> Self { 267 + BookContent { 268 + author: value.tag.author, 269 + pages: value.tag.pages, 270 + title: value.tag.title, 271 + generation: value.tag.generation.into(), 272 + resolved: value.tag.resolved, 273 + } 274 + } 275 + }
+65
nara_slurper_1_12_core/src/lib.rs
··· 1 + use nara_core::book::{Book, BookMetadata, BookSource}; 2 + 3 + use crate::item::{InventoryItemStack, ItemStack, WrittenBookStack}; 4 + 5 + pub mod chunk; 6 + pub mod item; 7 + 8 + pub fn extract_books_from_inventory( 9 + inventory: &[InventoryItemStack], 10 + ) -> Vec<WrittenBookStack> { 11 + let mut books: Vec<WrittenBookStack> = Vec::new(); 12 + 13 + inventory.iter().for_each(|item| match &item.item { 14 + ItemStack::WrittenBook(written_book_stack) => { 15 + books.push(written_book_stack.clone()) 16 + } 17 + ItemStack::BlockEntity(block_entity_stack) => { 18 + books.extend(extract_books_from_inventory( 19 + &block_entity_stack.tag.block_entity.items, 20 + )); 21 + } 22 + _ => {} 23 + }); 24 + 25 + books 26 + } 27 + 28 + pub fn extract_books_with_source( 29 + inventory: &[InventoryItemStack], 30 + nested_block_entity_id: Option<String>, 31 + nested_source: Option<BookSource>, 32 + make_root: &impl Fn(i8) -> BookSource, 33 + ) -> Vec<Book> { 34 + let mut books = Vec::new(); 35 + for item in inventory { 36 + let item_source = match &nested_source { 37 + Some(outer) => BookSource::ItemBlockEntity { 38 + id: nested_block_entity_id.clone().unwrap(), 39 + slot: item.slot, 40 + within: Box::new(outer.clone()), 41 + }, 42 + None => make_root(item.slot), 43 + }; 44 + match &item.item { 45 + ItemStack::WrittenBook(written_book) => { 46 + books.push(Book { 47 + metadata: BookMetadata { 48 + source: item_source, 49 + }, 50 + content: written_book.clone().into(), 51 + }); 52 + } 53 + ItemStack::BlockEntity(block_entity) => { 54 + books.extend(extract_books_with_source( 55 + &block_entity.tag.block_entity.items, 56 + Some(block_entity.id.clone()), 57 + Some(item_source), 58 + make_root, 59 + )); 60 + } 61 + _ => {} 62 + } 63 + } 64 + books 65 + }
+10
nara_slurper_1_12_infinity/Cargo.toml
··· 1 + [package] 2 + name = "nara_slurper_1_12_infinity" 3 + version.workspace = true 4 + edition.workspace = true 5 + 6 + [dependencies] 7 + nara_core.workspace = true 8 + nara_io.workspace = true 9 + nara_slurper_1_12_core.workspace = true 10 + serde.workspace = true
+5
nara_slurper_1_12_infinity/README.md
··· 1 + # nara_slurper_1_12_infinity 2 + 3 + Pulls books from an [Infinity Item Editor][iie] vault. 4 + 5 + [iie]: https://modrinth.com/mod/infinity-item-editor/version/0.15
+40
nara_slurper_1_12_infinity/src/lib.rs
··· 1 + use std::path::Path; 2 + 3 + use nara_core::book::{Book, BookSource}; 4 + use nara_slurper_1_12_core::{ 5 + extract_books_with_source, 6 + item::{InventoryItemStack, ItemStack}, 7 + }; 8 + use serde::Deserialize; 9 + 10 + #[derive(Debug, Deserialize)] 11 + pub struct Realm { 12 + pub realm: Vec<ItemStack>, 13 + pub realm_version: String, 14 + } 15 + 16 + impl Realm { 17 + pub fn read<P>(path: P) -> nara_io::Result<Realm> 18 + where 19 + P: AsRef<Path>, 20 + { 21 + let buffer = std::fs::read(path)?; 22 + nara_io::read_nbt(&buffer) 23 + } 24 + 25 + pub fn slurp(&self) -> Vec<Book> { 26 + let fake_inventory: Vec<InventoryItemStack> = self 27 + .realm 28 + .iter() 29 + .enumerate() 30 + .map(|(slot, item)| InventoryItemStack { 31 + item: item.clone(), 32 + slot: slot as i8, 33 + }) 34 + .collect(); 35 + 36 + extract_books_with_source(&fake_inventory, None, None, &|_| { 37 + BookSource::InfinityRealm 38 + }) 39 + } 40 + }
+20
nara_slurper_1_12_world/Cargo.toml
··· 1 + [package] 2 + name = "nara_slurper_1_12_world" 3 + version.workspace = true 4 + edition.workspace = true 5 + 6 + [dependencies] 7 + crab_nbt.workspace = true 8 + nara_mcr.workspace = true 9 + nara_slurper_1_12_core.workspace = true 10 + nara_io.workspace = true 11 + nara_core.workspace = true 12 + serde.workspace = true 13 + serde_with.workspace = true 14 + thiserror.workspace = true 15 + tokio.workspace = true 16 + tracing.workspace = true 17 + uuid.workspace = true 18 + 19 + [dev-dependencies] 20 + insta.workspace = true
+9
nara_slurper_1_12_world/README.md
··· 1 + # nara_slurper_1_12_world 2 + 3 + Pulls book data from everywhere it possibly can from Minecraft: Java Edition 4 + worlds saved in 1.12.2's world format. 5 + 6 + Checks: 7 + 8 + - entitydata 9 + - item frames
+218
nara_slurper_1_12_world/src/lib.rs
··· 1 + use std::{ 2 + path::{Path, PathBuf}, 3 + sync::{Arc, Mutex}, 4 + }; 5 + 6 + use nara_core::book::{Book, BookSource}; 7 + use nara_io::read_nbt; 8 + use nara_mcr::McRegion; 9 + use nara_slurper_1_12_core::{ 10 + chunk::{Chunk, Entity}, 11 + extract_books_with_source, 12 + item::InventoryItemStack, 13 + }; 14 + use tokio::task::JoinSet; 15 + 16 + use crate::playerdata::PlayerData; 17 + 18 + pub mod playerdata; 19 + 20 + #[derive(Debug, thiserror::Error)] 21 + pub enum Error { 22 + #[error(transparent)] 23 + Io(#[from] std::io::Error), 24 + #[error(transparent)] 25 + NaraIo(#[from] nara_io::Error), 26 + #[error(transparent)] 27 + McRegion(#[from] nara_mcr::Error), 28 + #[error("worker task panicked: {0}")] 29 + Join(#[from] tokio::task::JoinError), 30 + } 31 + 32 + pub type Result<T> = std::result::Result<T, Error>; 33 + 34 + enum Job { 35 + Playerdata(PathBuf), 36 + Region { path: PathBuf, dimension: String }, 37 + } 38 + 39 + pub async fn slurp_world( 40 + world_directory: &Path, 41 + num_workers: usize, 42 + ) -> Result<Vec<Book>> { 43 + let (tx, rx) = std::sync::mpsc::channel::<Job>(); 44 + let rx = Arc::new(Mutex::new(rx)); 45 + 46 + let mut set: JoinSet<Result<Vec<Book>>> = JoinSet::new(); 47 + for _ in 0..num_workers { 48 + let rx = Arc::clone(&rx); 49 + set.spawn_blocking(move || { 50 + let mut books = Vec::new(); 51 + loop { 52 + let job = rx.lock().unwrap().recv(); 53 + match job { 54 + Ok(Job::Playerdata(path)) => { 55 + books.extend(slurp_playerdata_file(&path)?); 56 + } 57 + Ok(Job::Region { path, dimension }) => { 58 + books.extend(slurp_region_file(&path, &dimension)?); 59 + } 60 + Err(_) => break, // channel closed, no more jobs 61 + } 62 + } 63 + Ok(books) 64 + }); 65 + } 66 + 67 + // Enumerate files and queue jobs. This is fast synchronous directory I/O 68 + // so it stays in the async task. 69 + let playerdata_dir = world_directory.join("playerdata"); 70 + if playerdata_dir.exists() { 71 + for entry in std::fs::read_dir(&playerdata_dir)? { 72 + let _ = tx.send(Job::Playerdata(entry?.path())); 73 + } 74 + } 75 + 76 + for (dimension_dir, dimension) in [( 77 + world_directory.to_owned(), 78 + world_directory 79 + .file_name() 80 + .unwrap() 81 + .to_os_string() 82 + .into_string() 83 + .unwrap(), 84 + )] { 85 + let region_dir = dimension_dir.join("region"); 86 + if !region_dir.exists() { 87 + continue; 88 + } 89 + for entry in std::fs::read_dir(&region_dir)? { 90 + let path = entry?.path(); 91 + if path.extension().and_then(|e| e.to_str()) != Some("mca") { 92 + continue; 93 + } 94 + let _ = tx.send(Job::Region { 95 + path, 96 + dimension: dimension.to_owned(), 97 + }); 98 + } 99 + } 100 + 101 + drop(tx); 102 + 103 + let mut books = Vec::new(); 104 + while let Some(result) = set.join_next().await { 105 + books.extend(result??); 106 + } 107 + Ok(books) 108 + } 109 + 110 + fn slurp_playerdata_file(path: &Path) -> Result<Vec<Book>> { 111 + let buffer = std::fs::read(path)?; 112 + let playerdata = read_nbt::<PlayerData>(&buffer)?; 113 + let books = playerdata.slurp(); 114 + if !books.is_empty() { 115 + tracing::debug!( 116 + "Slurped {} book(s) from {}", 117 + books.len(), 118 + playerdata.uuid() 119 + ); 120 + } 121 + Ok(books) 122 + } 123 + 124 + fn slurp_region_file(path: &Path, dimension: &str) -> Result<Vec<Book>> { 125 + tracing::debug!("Slurping {path:?}..."); 126 + let data = std::fs::read(path)?; 127 + let region = McRegion::<Chunk>::from_bytes(&data)?; 128 + 129 + let mut books = Vec::new(); 130 + for (_, chunk) in region.iter() { 131 + for e in &chunk.level.entities { 132 + match e { 133 + Entity::ItemHolder(item_holder_entity) => { 134 + let fake_inventory = vec![InventoryItemStack { 135 + slot: 0, 136 + item: item_holder_entity.item.clone(), 137 + }]; 138 + let slurped = extract_books_with_source( 139 + &fake_inventory, 140 + None, 141 + None, 142 + &|_| BookSource::Entity { 143 + dimension: dimension.to_string(), 144 + id: item_holder_entity.base.id.clone(), 145 + x: item_holder_entity.base.position[0], 146 + y: item_holder_entity.base.position[1], 147 + z: item_holder_entity.base.position[2], 148 + }, 149 + ); 150 + if !slurped.is_empty() { 151 + tracing::debug!( 152 + "Slurped a book from a {} at {:?} in chunk ({}, {}) of {path:?}", 153 + item_holder_entity.base.id, 154 + item_holder_entity.base.position, 155 + chunk.level.x_pos, 156 + chunk.level.z_pos 157 + ); 158 + } 159 + books.extend(slurped); 160 + } 161 + Entity::Inventoried(inventoried_entity) => { 162 + let slurped = extract_books_with_source( 163 + &inventoried_entity.items, 164 + None, 165 + None, 166 + &|slot| BookSource::EntityInventory { 167 + dimension: dimension.to_string(), 168 + id: inventoried_entity.base.id.clone(), 169 + x: inventoried_entity.base.position[0], 170 + y: inventoried_entity.base.position[1], 171 + z: inventoried_entity.base.position[2], 172 + slot, 173 + }, 174 + ); 175 + if !slurped.is_empty() { 176 + tracing::debug!( 177 + "Slurped {} book(s) from a {} at {:?} in chunk ({}, {}) of {path:?}", 178 + slurped.len(), 179 + inventoried_entity.base.id, 180 + inventoried_entity.base.position, 181 + chunk.level.x_pos, 182 + chunk.level.z_pos 183 + ); 184 + } 185 + books.extend(slurped); 186 + } 187 + _ => {} 188 + } 189 + } 190 + for te in &chunk.level.tile_entities { 191 + let slurped = 192 + extract_books_with_source(&te.items, None, None, &|slot| { 193 + BookSource::BlockEntity { 194 + dimension: dimension.to_string(), 195 + id: te.id.clone(), 196 + x: te.x, 197 + y: te.y, 198 + z: te.z, 199 + slot, 200 + } 201 + }); 202 + if !slurped.is_empty() { 203 + tracing::debug!( 204 + "Slurped {} book(s) from a {} at ({},{},{}) in chunk ({},{}) of {path:?}", 205 + slurped.len(), 206 + te.id, 207 + te.x, 208 + te.y, 209 + te.z, 210 + chunk.level.x_pos, 211 + chunk.level.z_pos, 212 + ); 213 + } 214 + books.extend(slurped); 215 + } 216 + } 217 + Ok(books) 218 + }
+58
nara_slurper_1_12_world/src/playerdata.rs
··· 1 + use nara_core::{ 2 + book::{Book, BookSource, PlayerInventoryKind}, 3 + i64_pair_to_uuid, 4 + }; 5 + use nara_slurper_1_12_core::{ 6 + extract_books_with_source, item::InventoryItemStack, 7 + }; 8 + use serde::Deserialize; 9 + use uuid::Uuid; 10 + 11 + // the shit we care about from playerdata.dat files :^) 12 + #[derive(Debug, Deserialize)] 13 + #[serde(rename_all = "PascalCase")] 14 + pub struct PlayerData { 15 + #[serde(rename = "UUIDLeast")] 16 + pub uuid_least: i64, 17 + #[serde(rename = "UUIDMost")] 18 + pub uuid_most: i64, 19 + #[serde(default)] 20 + pub inventory: Vec<InventoryItemStack>, 21 + #[serde(default, rename = "EnderItems")] 22 + pub ender_chest: Vec<InventoryItemStack>, 23 + } 24 + 25 + impl PlayerData { 26 + pub fn uuid(&self) -> Uuid { 27 + i64_pair_to_uuid(self.uuid_most, self.uuid_least) 28 + } 29 + 30 + pub fn slurp(&self) -> Vec<Book> { 31 + let uuid = self.uuid(); 32 + let mut books = Vec::new(); 33 + 34 + books.extend(extract_books_with_source( 35 + &self.inventory, 36 + None, 37 + None, 38 + &|slot| BookSource::PlayerData { 39 + uuid, 40 + inventory: PlayerInventoryKind::Inventory, 41 + slot, 42 + }, 43 + )); 44 + 45 + books.extend(extract_books_with_source( 46 + &self.ender_chest, 47 + None, 48 + None, 49 + &|slot| BookSource::PlayerData { 50 + uuid, 51 + inventory: PlayerInventoryKind::EnderChest, 52 + slot, 53 + }, 54 + )); 55 + 56 + books 57 + } 58 + }
+17
nara_slurper_1_12_world/tests/data.rs
··· 1 + use nara_slurper_1_12_world::playerdata::PlayerData; 2 + 3 + const PLAYER_DATA_FIXTURE: &[u8] = include_bytes!("fixtures/playerdata.dat"); 4 + 5 + fn get_playerdata() -> PlayerData { 6 + nara_io::read_nbt(PLAYER_DATA_FIXTURE).unwrap() 7 + } 8 + 9 + #[test] 10 + fn playerdata() { 11 + insta::assert_debug_snapshot!(get_playerdata()); 12 + } 13 + 14 + #[test] 15 + fn playerdata_slurp() { 16 + insta::assert_debug_snapshot!(get_playerdata().slurp()); 17 + }
nara_slurper_1_12_world/tests/fixtures/playerdata.dat

This is a binary file and will not be displayed.

+296
nara_slurper_1_12_world/tests/snapshots/data__playerdata.snap
··· 1 + --- 2 + source: nara_slurper_1_12_world/tests/data.rs 3 + expression: get_playerdata() 4 + --- 5 + PlayerData { 6 + uuid_least: -7869178909067405374, 7 + uuid_most: -8520075660724779485, 8 + inventory: [ 9 + InventoryItemStack { 10 + item: WrittenBook( 11 + WrittenBookStack { 12 + count: 1, 13 + damage: 0, 14 + id: "minecraft:written_book", 15 + tag: WrittenBookTag { 16 + base: BaseTag { 17 + display: None, 18 + }, 19 + author: "kokiriglade", 20 + title: "just a book", 21 + generation: Original, 22 + resolved: false, 23 + pages: [ 24 + Array( 25 + [ 26 + Object( 27 + ComponentObject { 28 + text: Some( 29 + TextComponent { 30 + text: "Hello, World!\n\n", 31 + }, 32 + ), 33 + translation: None, 34 + score: None, 35 + selector: None, 36 + keybind: None, 37 + nbt: None, 38 + object: None, 39 + formatting: ComponentFormatting { 40 + color: None, 41 + font: None, 42 + bold: None, 43 + italic: None, 44 + underlined: None, 45 + strikethrough: None, 46 + obfuscated: None, 47 + }, 48 + children: [], 49 + }, 50 + ), 51 + Object( 52 + ComponentObject { 53 + text: Some( 54 + TextComponent { 55 + text: "This is red text!", 56 + }, 57 + ), 58 + translation: None, 59 + score: None, 60 + selector: None, 61 + keybind: None, 62 + nbt: None, 63 + object: None, 64 + formatting: ComponentFormatting { 65 + color: Some( 66 + Named( 67 + Red, 68 + ), 69 + ), 70 + font: None, 71 + bold: None, 72 + italic: None, 73 + underlined: None, 74 + strikethrough: None, 75 + obfuscated: None, 76 + }, 77 + children: [], 78 + }, 79 + ), 80 + Object( 81 + ComponentObject { 82 + text: Some( 83 + TextComponent { 84 + text: "\n\n", 85 + }, 86 + ), 87 + translation: None, 88 + score: None, 89 + selector: None, 90 + keybind: None, 91 + nbt: None, 92 + object: None, 93 + formatting: ComponentFormatting { 94 + color: None, 95 + font: None, 96 + bold: None, 97 + italic: None, 98 + underlined: None, 99 + strikethrough: None, 100 + obfuscated: None, 101 + }, 102 + children: [], 103 + }, 104 + ), 105 + Object( 106 + ComponentObject { 107 + text: Some( 108 + TextComponent { 109 + text: "This is blue :^)", 110 + }, 111 + ), 112 + translation: None, 113 + score: None, 114 + selector: None, 115 + keybind: None, 116 + nbt: None, 117 + object: None, 118 + formatting: ComponentFormatting { 119 + color: Some( 120 + Named( 121 + Blue, 122 + ), 123 + ), 124 + font: None, 125 + bold: None, 126 + italic: None, 127 + underlined: None, 128 + strikethrough: None, 129 + obfuscated: None, 130 + }, 131 + children: [], 132 + }, 133 + ), 134 + ], 135 + ), 136 + ], 137 + }, 138 + }, 139 + ), 140 + slot: 0, 141 + }, 142 + InventoryItemStack { 143 + item: BlockEntity( 144 + BlockEntityStack { 145 + count: 1, 146 + damage: 0, 147 + id: "minecraft:pink_shulker_box", 148 + tag: BlockEntityTag { 149 + base: BaseTag { 150 + display: None, 151 + }, 152 + block_entity: BlockEntity { 153 + items: [ 154 + InventoryItemStack { 155 + item: WrittenBook( 156 + WrittenBookStack { 157 + count: 1, 158 + damage: 0, 159 + id: "minecraft:written_book", 160 + tag: WrittenBookTag { 161 + base: BaseTag { 162 + display: None, 163 + }, 164 + author: "kokiriglade", 165 + title: "in a shulker box", 166 + generation: Original, 167 + resolved: false, 168 + pages: [ 169 + Object( 170 + ComponentObject { 171 + text: Some( 172 + TextComponent { 173 + text: "this one is in a shulker box!! wow!!!", 174 + }, 175 + ), 176 + translation: None, 177 + score: None, 178 + selector: None, 179 + keybind: None, 180 + nbt: None, 181 + object: None, 182 + formatting: ComponentFormatting { 183 + color: None, 184 + font: None, 185 + bold: None, 186 + italic: None, 187 + underlined: None, 188 + strikethrough: None, 189 + obfuscated: None, 190 + }, 191 + children: [], 192 + }, 193 + ), 194 + ], 195 + }, 196 + }, 197 + ), 198 + slot: 2, 199 + }, 200 + ], 201 + }, 202 + }, 203 + }, 204 + ), 205 + slot: 1, 206 + }, 207 + InventoryItemStack { 208 + item: Base( 209 + BaseItemStack { 210 + count: 1, 211 + damage: 0, 212 + id: "minecraft:stone", 213 + }, 214 + ), 215 + slot: 2, 216 + }, 217 + InventoryItemStack { 218 + item: Base( 219 + BaseItemStack { 220 + count: 1, 221 + damage: 0, 222 + id: "minecraft:reeds", 223 + }, 224 + ), 225 + slot: 4, 226 + }, 227 + InventoryItemStack { 228 + item: Base( 229 + BaseItemStack { 230 + count: 1, 231 + damage: 0, 232 + id: "minecraft:golden_chestplate", 233 + }, 234 + ), 235 + slot: 5, 236 + }, 237 + InventoryItemStack { 238 + item: Base( 239 + BaseItemStack { 240 + count: 1, 241 + damage: 0, 242 + id: "minecraft:ender_chest", 243 + }, 244 + ), 245 + slot: 7, 246 + }, 247 + ], 248 + ender_chest: [ 249 + InventoryItemStack { 250 + item: WrittenBook( 251 + WrittenBookStack { 252 + count: 1, 253 + damage: 0, 254 + id: "minecraft:written_book", 255 + tag: WrittenBookTag { 256 + base: BaseTag { 257 + display: None, 258 + }, 259 + author: "kokiriglade", 260 + title: "ender book :^)", 261 + generation: Original, 262 + resolved: false, 263 + pages: [ 264 + Object( 265 + ComponentObject { 266 + text: Some( 267 + TextComponent { 268 + text: "this ones in my ender chest !! wow !!!!", 269 + }, 270 + ), 271 + translation: None, 272 + score: None, 273 + selector: None, 274 + keybind: None, 275 + nbt: None, 276 + object: None, 277 + formatting: ComponentFormatting { 278 + color: None, 279 + font: None, 280 + bold: None, 281 + italic: None, 282 + underlined: None, 283 + strikethrough: None, 284 + obfuscated: None, 285 + }, 286 + children: [], 287 + }, 288 + ), 289 + ], 290 + }, 291 + }, 292 + ), 293 + slot: 16, 294 + }, 295 + ], 296 + }
+223
nara_slurper_1_12_world/tests/snapshots/data__playerdata_slurp.snap
··· 1 + --- 2 + source: nara_slurper_1_12_world/tests/data.rs 3 + expression: get_playerdata().slurp() 4 + --- 5 + [ 6 + Book { 7 + metadata: BookMetadata { 8 + source: PlayerData { 9 + uuid: 89c29c53-ef0c-4623-92cb-0f79930a97c2, 10 + inventory: Inventory, 11 + slot: 0, 12 + }, 13 + }, 14 + content: BookContent { 15 + author: "kokiriglade", 16 + pages: [ 17 + Array( 18 + [ 19 + Object( 20 + ComponentObject { 21 + text: Some( 22 + TextComponent { 23 + text: "Hello, World!\n\n", 24 + }, 25 + ), 26 + translation: None, 27 + score: None, 28 + selector: None, 29 + keybind: None, 30 + nbt: None, 31 + object: None, 32 + formatting: ComponentFormatting { 33 + color: None, 34 + font: None, 35 + bold: None, 36 + italic: None, 37 + underlined: None, 38 + strikethrough: None, 39 + obfuscated: None, 40 + }, 41 + children: [], 42 + }, 43 + ), 44 + Object( 45 + ComponentObject { 46 + text: Some( 47 + TextComponent { 48 + text: "This is red text!", 49 + }, 50 + ), 51 + translation: None, 52 + score: None, 53 + selector: None, 54 + keybind: None, 55 + nbt: None, 56 + object: None, 57 + formatting: ComponentFormatting { 58 + color: Some( 59 + Named( 60 + Red, 61 + ), 62 + ), 63 + font: None, 64 + bold: None, 65 + italic: None, 66 + underlined: None, 67 + strikethrough: None, 68 + obfuscated: None, 69 + }, 70 + children: [], 71 + }, 72 + ), 73 + Object( 74 + ComponentObject { 75 + text: Some( 76 + TextComponent { 77 + text: "\n\n", 78 + }, 79 + ), 80 + translation: None, 81 + score: None, 82 + selector: None, 83 + keybind: None, 84 + nbt: None, 85 + object: None, 86 + formatting: ComponentFormatting { 87 + color: None, 88 + font: None, 89 + bold: None, 90 + italic: None, 91 + underlined: None, 92 + strikethrough: None, 93 + obfuscated: None, 94 + }, 95 + children: [], 96 + }, 97 + ), 98 + Object( 99 + ComponentObject { 100 + text: Some( 101 + TextComponent { 102 + text: "This is blue :^)", 103 + }, 104 + ), 105 + translation: None, 106 + score: None, 107 + selector: None, 108 + keybind: None, 109 + nbt: None, 110 + object: None, 111 + formatting: ComponentFormatting { 112 + color: Some( 113 + Named( 114 + Blue, 115 + ), 116 + ), 117 + font: None, 118 + bold: None, 119 + italic: None, 120 + underlined: None, 121 + strikethrough: None, 122 + obfuscated: None, 123 + }, 124 + children: [], 125 + }, 126 + ), 127 + ], 128 + ), 129 + ], 130 + title: "just a book", 131 + generation: Original, 132 + resolved: false, 133 + }, 134 + }, 135 + Book { 136 + metadata: BookMetadata { 137 + source: ItemBlockEntity { 138 + id: "minecraft:pink_shulker_box", 139 + slot: 2, 140 + within: PlayerData { 141 + uuid: 89c29c53-ef0c-4623-92cb-0f79930a97c2, 142 + inventory: Inventory, 143 + slot: 1, 144 + }, 145 + }, 146 + }, 147 + content: BookContent { 148 + author: "kokiriglade", 149 + pages: [ 150 + Object( 151 + ComponentObject { 152 + text: Some( 153 + TextComponent { 154 + text: "this one is in a shulker box!! wow!!!", 155 + }, 156 + ), 157 + translation: None, 158 + score: None, 159 + selector: None, 160 + keybind: None, 161 + nbt: None, 162 + object: None, 163 + formatting: ComponentFormatting { 164 + color: None, 165 + font: None, 166 + bold: None, 167 + italic: None, 168 + underlined: None, 169 + strikethrough: None, 170 + obfuscated: None, 171 + }, 172 + children: [], 173 + }, 174 + ), 175 + ], 176 + title: "in a shulker box", 177 + generation: Original, 178 + resolved: false, 179 + }, 180 + }, 181 + Book { 182 + metadata: BookMetadata { 183 + source: PlayerData { 184 + uuid: 89c29c53-ef0c-4623-92cb-0f79930a97c2, 185 + inventory: EnderChest, 186 + slot: 16, 187 + }, 188 + }, 189 + content: BookContent { 190 + author: "kokiriglade", 191 + pages: [ 192 + Object( 193 + ComponentObject { 194 + text: Some( 195 + TextComponent { 196 + text: "this ones in my ender chest !! wow !!!!", 197 + }, 198 + ), 199 + translation: None, 200 + score: None, 201 + selector: None, 202 + keybind: None, 203 + nbt: None, 204 + object: None, 205 + formatting: ComponentFormatting { 206 + color: None, 207 + font: None, 208 + bold: None, 209 + italic: None, 210 + underlined: None, 211 + strikethrough: None, 212 + obfuscated: None, 213 + }, 214 + children: [], 215 + }, 216 + ), 217 + ], 218 + title: "ender book :^)", 219 + generation: Original, 220 + resolved: false, 221 + }, 222 + }, 223 + ]
+69 -5
src/cli.rs
··· 1 1 use std::path::PathBuf; 2 2 3 - use clap::{ArgAction, Parser}; 3 + use clap::{ArgAction, Args, Parser, Subcommand}; 4 4 5 5 use crate::web::TextureKind; 6 6 7 7 #[derive(Parser, Debug)] 8 8 #[command(version, about, long_about = None)] 9 - pub struct Args { 9 + pub struct Cli { 10 + #[command(subcommand)] 11 + pub command: Command, 12 + } 13 + 14 + #[derive(Subcommand, Debug)] 15 + pub enum Command { 16 + /// Build the library and optionally run the webserver. 17 + Serve(ServeArgs), 18 + /// Slurp a world directory. 19 + SlurpWorld(SlurpWorldArgs), 20 + /// Slurp an Infinity Item Editor realm. 21 + SlurpRealm(SlurpRealmArgs), 22 + } 23 + 24 + #[derive(Debug, Clone, Copy, clap::ValueEnum)] 25 + #[clap(rename_all = "snake_case")] 26 + pub enum MinecraftVersion { 27 + V1_12, 28 + } 29 + 30 + #[derive(Args, Debug)] 31 + pub struct SlurpRealmArgs { 32 + #[arg(short = 'v', long = "version", default_value = "v1_12")] 33 + pub version: MinecraftVersion, 34 + 10 35 /// The path to the `realm.nbt` file created by Infinity Item Editor. 11 36 #[arg(short = 'r', long = "realm", default_value = "realm.nbt")] 12 37 pub realm_path: PathBuf, 13 38 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, 39 + /// Path to the book container to create or update. 40 + #[arg( 41 + short = 'c', 42 + long = "container", 43 + default_value = "container.json.gz" 44 + )] 45 + pub container_path: PathBuf, 46 + } 47 + 48 + #[derive(Args, Debug)] 49 + pub struct SlurpWorldArgs { 50 + #[arg(short = 'v', long = "version", default_value = "v1_12")] 51 + pub version: MinecraftVersion, 52 + 53 + /// Path to the world directory. 54 + #[arg(short = 'w', long = "world")] 55 + pub world_path: PathBuf, 56 + 57 + /// Number of worker threads used for parallel file slurping. 58 + /// Defaults to the number of logical CPUs. 59 + #[arg(short = 'j', long = "workers")] 60 + pub workers: Option<usize>, 61 + 62 + /// Path to the book container to create or update. 63 + #[arg( 64 + short = 'c', 65 + long = "container", 66 + default_value = "container.json.gz" 67 + )] 68 + pub container_path: PathBuf, 69 + } 70 + 71 + #[derive(Args, Debug)] 72 + pub struct ServeArgs { 17 73 /// Whether to warn about empty/whitespace books being skipped during indexing. 18 74 #[arg(short = 'e', long, default_value_t = true, action = ArgAction::Set)] 19 75 pub warn_empty: bool, ··· 30 86 /// The score threshold for fuzzy finding book authors. 31 87 #[arg(short = 'A', long, default_value_t = 0.82)] 32 88 pub author_threshold: f64, 89 + 90 + /// Path to the book container to load. 91 + #[arg( 92 + short = 'c', 93 + long = "container", 94 + default_value = "container.json.gz" 95 + )] 96 + pub container_path: PathBuf, 33 97 34 98 #[arg(short = 's', long = "dont-start-webserver", action = ArgAction::SetFalse)] 35 99 pub start_webserver: bool,
-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 - }
+45 -244
src/library.rs
··· 1 - use std::time::{SystemTime, SystemTimeError}; 2 - 3 1 use ahash::{AHashMap, AHashSet}; 4 2 use lru::LruCache; 3 + use nara_core::{ 4 + book::{Book, BookHash, BookSource}, 5 + component::Component, 6 + }; 5 7 use smol_str::SmolStr; 6 8 use std::{cell::RefCell, num::NonZeroUsize}; 7 9 use strsim::jaro_winkler; 8 10 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 11 pub type BookId = usize; 20 12 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 13 /// In-memory index of all books with lookup and fuzzy search helpers. 29 14 #[derive(Debug)] 30 15 pub struct Library { 31 - books: Vec<WrittenBookTag>, 16 + books: Vec<Book>, 32 17 33 18 by_hash: AHashMap<BookHash, BookId>, 34 - location_by_hash: AHashMap<BookHash, SmolStr>, 35 - by_category: AHashMap<SmolStr, Vec<BookId>>, 19 + source_by_hash: AHashMap<BookHash, BookSource>, 36 20 by_author_lc: AHashMap<SmolStr, Vec<BookId>>, 37 21 38 22 // normalized blobs for scoring (same index as `books`) ··· 50 34 title_threshold: f64, 51 35 52 36 cache_books_by_author: RefCell<LruCache<SmolStr, Vec<BookId>>>, 53 - cache_books_in_category: RefCell<LruCache<SmolStr, Vec<BookId>>>, 54 37 cache_fuzzy_title: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 55 38 cache_fuzzy_author: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 56 39 cache_fuzzy_contents: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 57 40 cache_fuzzy_all: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>, 58 41 59 - duplicate_books_filtered: u16, 60 42 empty_books_filtered: u16, 61 43 } 62 44 ··· 67 49 limit: usize, 68 50 } 69 51 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 52 impl Library { 83 - /// Builds a library index from a realm export. 84 53 pub fn new( 85 - realm: Realm, 86 54 content_threshold: f64, 87 - author_threshold: f64, 88 55 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 { 56 + author_threshold: f64, 57 + ) -> Self { 58 + Self { 101 59 books: Vec::new(), 102 60 by_hash: AHashMap::new(), 103 - location_by_hash: AHashMap::new(), 104 - by_category: AHashMap::new(), 61 + source_by_hash: AHashMap::new(), 105 62 by_author_lc: AHashMap::new(), 106 63 norm_title: Vec::new(), 107 64 norm_author: Vec::new(), ··· 110 67 tri_author: AHashMap::new(), 111 68 tri_contents: AHashMap::new(), 112 69 content_threshold, 113 - author_threshold, 114 70 title_threshold, 71 + author_threshold, 115 72 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 73 cache_fuzzy_title: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 120 74 cache_fuzzy_author: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 121 75 cache_fuzzy_contents: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 122 76 cache_fuzzy_all: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 123 - duplicate_books_filtered: 0, 124 77 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 78 } 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 79 } 191 80 192 - /// Inserts a book and updates all indices and caches. 193 - fn add_book( 81 + /// Inserts a book 82 + pub fn add_book( 194 83 &mut self, 195 - mut book: WrittenBookTag, 196 - location: Option<SmolStr>, 197 - warn_duplicates: bool, 84 + book: Book, 198 85 warn_empty: bool, 199 86 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 - } 87 + ) { 206 88 if filter_empty_books && book_plain_text_empty(&book) { 207 89 if warn_empty { 208 90 tracing::warn!( 209 - "Skipping empty book in location {0:?}: {1} by {2}", 210 - location, 211 - book.title, 212 - book.author 91 + "Skipping empty book with source {0:?}: {1} by {2}", 92 + book.metadata.source, 93 + book.content.title, 94 + book.content.author 213 95 ); 214 96 } 215 97 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)); 98 + return; 225 99 }; 226 100 227 101 let h = book.hash(); 228 102 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(()); 103 + return; 242 104 } 243 105 244 106 let id = self.books.len(); 107 + 108 + let source = book.metadata.source.clone(); 245 109 self.books.push(book); 246 110 247 111 // indices... 248 112 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); 113 + self.source_by_hash.insert(h, source); 254 114 255 - let author_lc = SmolStr::new(normalize(&self.books[id].author)); 115 + let author_lc = SmolStr::new(normalize(&self.books[id].content.author)); 256 116 if !author_lc.is_empty() { 257 117 self.by_author_lc.entry(author_lc).or_default().push(id); 258 118 } 259 119 260 120 // normalized blobs (for scoring) 261 - self.norm_title.push(normalize(&self.books[id].title)); 262 - self.norm_author.push(normalize(&self.books[id].author)); 121 + self.norm_title 122 + .push(normalize(&self.books[id].content.title)); 123 + self.norm_author 124 + .push(normalize(&self.books[id].content.author)); 263 125 self.norm_contents 264 - .push(normalize_contents(&self.books[id].pages)); 126 + .push(normalize_contents(&self.books[id].content.pages)); 265 127 266 128 // candidate-generation indices 267 129 index_trigrams(&mut self.tri_title, id, &self.norm_title[id]); ··· 269 131 index_trigrams(&mut self.tri_contents, id, &self.norm_contents[id]); 270 132 271 133 self.cache_books_by_author.borrow_mut().clear(); 272 - self.cache_books_in_category.borrow_mut().clear(); 273 134 self.cache_fuzzy_title.borrow_mut().clear(); 274 135 self.cache_fuzzy_author.borrow_mut().clear(); 275 136 self.cache_fuzzy_contents.borrow_mut().clear(); 276 137 self.cache_fuzzy_all.borrow_mut().clear(); 277 - 278 - Ok(()) 279 138 } 280 139 281 140 /// Looks up a book by its content hash. 282 141 #[inline] 283 - pub fn book_by_hash(&self, hash: BookHash) -> Option<&WrittenBookTag> { 142 + pub fn book_by_hash(&self, hash: BookHash) -> Option<&Book> { 284 143 self.by_hash.get(&hash).map(|&id| &self.books[id]) 285 144 } 286 145 ··· 289 148 pub fn books_by_author<'a>( 290 149 &'a self, 291 150 author: &str, 292 - ) -> impl Iterator<Item = &'a WrittenBookTag> + 'a { 151 + ) -> impl Iterator<Item = &'a Book> + 'a { 293 152 let key = SmolStr::new(normalize(author)); 294 153 let ids = if key.is_empty() { 295 154 Vec::new() ··· 308 167 ids.into_iter().map(|id| &self.books[id]) 309 168 } 310 169 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 170 /// Fuzzy search over normalized titles. 336 - pub fn fuzzy_title( 337 - &self, 338 - query: &str, 339 - limit: usize, 340 - ) -> Vec<(&WrittenBookTag, f64)> { 171 + pub fn fuzzy_title(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> { 341 172 let key = SmolStr::new(normalize(query)); 342 173 if key.is_empty() || limit == 0 { 343 174 return Vec::new(); ··· 373 204 } 374 205 375 206 /// Fuzzy search over normalized author names. 376 - pub fn fuzzy_author( 377 - &self, 378 - query: &str, 379 - limit: usize, 380 - ) -> Vec<(&WrittenBookTag, f64)> { 207 + pub fn fuzzy_author(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> { 381 208 let key = SmolStr::new(normalize(query)); 382 209 if key.is_empty() || limit == 0 { 383 210 return Vec::new(); ··· 420 247 &self, 421 248 query: &str, 422 249 limit: usize, 423 - ) -> Vec<(&WrittenBookTag, f64)> { 250 + ) -> Vec<(&Book, f64)> { 424 251 let key = SmolStr::new(normalize(query)); 425 252 if key.is_empty() || limit == 0 { 426 253 return Vec::new(); ··· 459 286 } 460 287 461 288 /// Combined fuzzy search (title + author + contents). 462 - pub fn fuzzy( 463 - &self, 464 - query: &str, 465 - limit: usize, 466 - ) -> Vec<(&WrittenBookTag, f64)> { 289 + pub fn fuzzy(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> { 467 290 let key = SmolStr::new(normalize(query)); 468 291 if key.is_empty() || limit == 0 { 469 292 return Vec::new(); ··· 540 363 541 364 /// Returns a list of all books in the library. 542 365 #[inline] 543 - pub fn all_books<'a>( 544 - &'a self, 545 - ) -> impl Iterator<Item = &'a WrittenBookTag> + 'a { 366 + pub fn all_books<'a>(&'a self) -> impl Iterator<Item = &'a Book> + 'a { 546 367 self.books.iter() 547 368 } 548 369 549 - /// Returns all categories with their book counts. 370 + /// Returns the source for a book hash, if present. 550 371 #[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) 372 + pub fn source_for_hash(&self, hash: &BookHash) -> Option<&BookSource> { 373 + self.source_by_hash.get(hash) 562 374 } 563 375 } 564 376 ··· 568 380 s.to_lowercase() 569 381 } 570 382 571 - fn book_plain_text_empty(book: &WrittenBookTag) -> bool { 572 - book.pages.is_empty() 383 + fn book_plain_text_empty(book: &Book) -> bool { 384 + book.content.pages.is_empty() 573 385 || book 386 + .content 574 387 .pages 575 388 .iter() 576 - .all(|page| normalize_page(page).trim().is_empty()) 389 + .all(|page| page.normalize().trim().is_empty()) 577 390 } 578 391 579 392 const CACHE_BY_AUTHOR_CAP: usize = 1024; 580 - const CACHE_BY_CATEGORY_CAP: usize = 1024; 581 393 const CACHE_FUZZY_CAP: usize = 256; 582 394 583 395 /// Helper to build LRU caches with non-zero capacity. 584 396 fn new_lru<K: std::hash::Hash + Eq, V>(cap: usize) -> LruCache<K, V> { 585 397 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 398 } 598 399 599 400 const MAX_CONTENT_INDEX_CHARS: usize = 16_384; ··· 784 585 fn fuzzy_rank_contents( 785 586 query: &str, 786 587 norm_field: &[String], 787 - tri_index: &ahash::AHashMap<u32, Vec<BookId>>, 588 + tri_index: &AHashMap<u32, Vec<BookId>>, 788 589 limit: usize, 789 590 score_threshold: f64, 790 591 ) -> Vec<(BookId, f64)> {
+90 -31
src/main.rs
··· 1 - use std::{fs, io::Cursor}; 1 + use std::time::{Instant, SystemTime}; 2 2 3 3 use anyhow::Context; 4 4 use clap::Parser as _; 5 + use nara_core::BookContainer; 6 + use nara_slurper_1_12_infinity::Realm; 5 7 6 8 use crate::{ 7 - cli::Args, 8 - library::{Library, Realm}, 9 + cli::{Cli, Command, ServeArgs}, 10 + library::Library, 9 11 web::start_webserver, 10 12 }; 13 + use nara_slurper_1_12_world::slurp_world; 11 14 12 15 pub mod cli; 13 16 pub mod library; ··· 18 21 #[tokio::main] 19 22 async fn main() -> anyhow::Result<()> { 20 23 tracing_subscriber::fmt::init(); 21 - let cli_args = Args::parse(); 22 - let library = build_library(&cli_args).context("Building library")?; 24 + let cli = Cli::parse(); 25 + 26 + match cli.command { 27 + Command::Serve(args) => { 28 + let library = build_library(&args).context("Building library")?; 23 29 24 - if cli_args.start_webserver { 25 - start_webserver(&cli_args, library) 26 - .await 27 - .context("Running webserver")?; 30 + if args.start_webserver { 31 + start_webserver(&args, library) 32 + .await 33 + .context("Running webserver")?; 34 + } 35 + } 36 + Command::SlurpWorld(args) => match args.version { 37 + cli::MinecraftVersion::V1_12 => { 38 + let num_workers = args.workers.unwrap_or_else(|| { 39 + std::thread::available_parallelism() 40 + .map(|n| n.get()) 41 + .unwrap_or(4) 42 + }); 43 + let mut container = 44 + BookContainer::open_or_new(&args.container_path) 45 + .context("Opening container")?; 46 + let start = Instant::now(); 47 + let books = slurp_world(&args.world_path, num_workers) 48 + .await 49 + .context("Slurping world")?; 50 + let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0; 51 + let total = books.len(); 52 + let dupes = books 53 + .into_iter() 54 + .filter(|b| !container.add(b.clone())) 55 + .count(); 56 + container 57 + .save(&args.container_path) 58 + .context("Saving container")?; 59 + tracing::info!( 60 + "Found {total} books, of which {dupes} were duplicates in {elapsed_ms:.3} ms" 61 + ); 62 + } 63 + }, 64 + Command::SlurpRealm(args) => match args.version { 65 + cli::MinecraftVersion::V1_12 => { 66 + let mut container = 67 + BookContainer::open_or_new(&args.container_path) 68 + .context("Opening container")?; 69 + let start = Instant::now(); 70 + let books = Realm::read(args.realm_path)?.slurp(); 71 + let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0; 72 + let total = books.len(); 73 + let dupes = books 74 + .into_iter() 75 + .filter(|b| !container.add(b.clone())) 76 + .count(); 77 + container 78 + .save(&args.container_path) 79 + .context("Saving container")?; 80 + tracing::info!( 81 + "Found {total} books, of which {dupes} were duplicates in {elapsed_ms:.3} ms" 82 + ); 83 + } 84 + }, 28 85 } 29 86 30 87 Ok(()) 31 88 } 32 89 33 - fn build_library(args: &Args) -> anyhow::Result<Library> { 34 - let realm_path = &args.realm_path; 35 - 36 - if !fs::exists(realm_path) 37 - .context("Checking if supplied realm path exists")? 38 - { 39 - return Err(anyhow::anyhow!("File {:?} not found", realm_path)); 40 - } 41 - 42 - let bytes = fs::read(realm_path).context("Reading realm")?; 43 - let mut cursor: Cursor<&[u8]> = Cursor::new(&bytes); 44 - let realm = crab_nbt::serde::de::from_cursor::<Realm>(&mut cursor) 45 - .context("Reading realm NBT")?; 90 + fn build_library(args: &ServeArgs) -> anyhow::Result<Library> { 91 + let start = SystemTime::now(); 92 + tracing::info!("Opening book container..."); 93 + let container = BookContainer::open_or_new(&args.container_path) 94 + .context("Opening container")?; 95 + tracing::info!( 96 + "Container opened, took {} ms. Building library...", 97 + SystemTime::now().duration_since(start)?.as_nanos() / 1_000_000 98 + ); 46 99 47 - let library = Library::new( 48 - realm, 100 + let start = SystemTime::now(); 101 + let mut library = Library::new( 49 102 args.content_threshold, 50 - args.author_threshold, 51 103 args.title_threshold, 52 - args.warn_duplicates, 53 - args.warn_empty, 54 - args.filter_empty_books, 55 - ) 56 - .context("Constructing library")?; 57 - 104 + args.author_threshold, 105 + ); 106 + for book in container.books() { 107 + library.add_book( 108 + book.clone(), 109 + args.warn_empty, 110 + args.filter_empty_books, 111 + ); 112 + } 113 + tracing::info!( 114 + "Built library in {}ms", 115 + SystemTime::now().duration_since(start)?.as_nanos() / 1_000_000 116 + ); 58 117 Ok(library) 59 118 }
+7 -79
src/web/api.rs
··· 7 7 response::{IntoResponse, Response}, 8 8 routing::get, 9 9 }; 10 + use nara_core::book::Book; 10 11 use serde::{Deserialize, Serialize}; 11 12 12 - use crate::library::{self, Library, item::WrittenBookTag, text::Component}; 13 + use crate::library::Library; 13 14 14 15 #[derive(Clone)] 15 16 pub struct AppState { ··· 23 24 .route("/health", get(health)) 24 25 .route("/books", get(list_books)) 25 26 .route("/books/by-author", get(books_by_author)) 26 - .route("/books/by-category", get(books_by_category)) 27 27 .route("/books/by-hash/{hash}", get(book_by_hash)) 28 - .route("/categories", get(list_categories)) 29 28 .route("/search", get(search_all)) 30 29 .route("/search/title", get(search_title)) 31 30 .route("/search/author", get(search_author)) ··· 36 35 #[derive(serde::Serialize)] 37 36 struct RootResponse { 38 37 message: &'static str, 39 - endpoints: [&'static str; 10], 38 + endpoints: [&'static str; 8], 40 39 } 41 40 42 41 async fn root() -> axum::Json<RootResponse> { ··· 46 45 "/api/health", 47 46 "/api/books?limit=25&offset=0", 48 47 "/api/books/by-author?author=Name&limit=25&offset=0", 49 - "/api/books/by-category?category=Category&limit=25&offset=0", 50 48 "/api/books/by-hash/:hash", 51 - "/api/categories", 52 49 "/api/search?query=term&limit=25&offset=0", 53 50 "/api/search/title?query=term&limit=25&offset=0", 54 51 "/api/search/author?query=term&limit=25&offset=0", ··· 139 136 Ok(Json(items)) 140 137 } 141 138 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 139 async fn book_by_hash( 173 140 State(state): State<AppState>, 174 141 Path(hash): Path<String>, ··· 182 149 Ok(Json(book_to_detail(book, &library))) 183 150 } 184 151 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 152 #[derive(Deserialize)] 208 153 struct SearchQuery { 209 154 query: String, ··· 252 197 fn run_search( 253 198 library: &Library, 254 199 query: SearchQuery, 255 - search_fn: for<'a> fn( 256 - &'a Library, 257 - &str, 258 - usize, 259 - ) -> Vec<(&'a WrittenBookTag, f64)>, 200 + search_fn: for<'a> fn(&'a Library, &str, usize) -> Vec<(&'a Book, f64)>, 260 201 ) -> Result<Json<Vec<SearchResult>>, ApiError> { 261 202 let query_str = query.query.trim(); 262 203 if query_str.is_empty() { ··· 281 222 282 223 #[derive(Serialize)] 283 224 struct BookDetail { 284 - title: String, 285 - author: String, 286 - pages: Vec<Component>, 225 + book: Book, 287 226 hash: String, 288 - location: Option<String>, 289 - category: Option<String>, 290 227 } 291 228 292 - fn book_to_detail(book: &WrittenBookTag, library: &Library) -> BookDetail { 229 + fn book_to_detail(book: &Book, _library: &Library) -> BookDetail { 293 230 let hash = book.hash(); 294 231 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 232 301 233 BookDetail { 302 - title: book.title.clone(), 303 - author: book.author.clone(), 304 - pages: book.pages.clone(), 234 + book: book.clone(), 305 235 hash: hash_hex, 306 - location, 307 - category, 308 236 } 309 237 } 310 238
+63 -84
src/web/assets/stylesheet.css
··· 1 1 @font-face { 2 2 font-family: "Minecraft"; 3 - src: url("/assets/font/regular.woff2?v=__NARA_ASSET_VERSION__") format("woff2"); 3 + src: url("/assets/font/regular.woff2?v=__NARA_ASSET_VERSION__") 4 + format("woff2"); 4 5 font-weight: 400; 5 6 font-style: normal; 6 7 font-display: swap; ··· 16 17 17 18 @font-face { 18 19 font-family: "Minecraft"; 19 - src: url("/assets/font/italic.woff2?v=__NARA_ASSET_VERSION__") format("woff2"); 20 + src: url("/assets/font/italic.woff2?v=__NARA_ASSET_VERSION__") 21 + format("woff2"); 20 22 font-weight: 400; 21 23 font-style: italic; 22 24 font-display: swap; ··· 24 26 25 27 @font-face { 26 28 font-family: "Minecraft"; 27 - src: url("/assets/font/bold_italic.woff2?v=__NARA_ASSET_VERSION__") format("woff2"); 29 + src: url("/assets/font/bold_italic.woff2?v=__NARA_ASSET_VERSION__") 30 + format("woff2"); 28 31 font-weight: 700; 29 32 font-style: italic; 30 33 font-display: swap; 31 34 } 32 35 33 36 .font-minecraft { 34 - font-family: "Minecraft", system-ui, -apple-system, "Segoe UI", sans-serif; 37 + font-family: 38 + "Minecraft", 39 + system-ui, 40 + -apple-system, 41 + "Segoe UI", 42 + sans-serif; 35 43 font-synthesis: none; 36 44 } 37 45 ··· 44 52 } 45 53 46 54 .text-dark_blue { 47 - color: #0000AA; 55 + color: #0000aa; 48 56 } 49 57 50 58 .text-dark_green { 51 - color: #00AA00; 59 + color: #00aa00; 52 60 } 53 61 54 62 .text-dark_aqua { 55 - color: #00AAAA; 63 + color: #00aaaa; 56 64 } 57 65 58 66 .text-dark_red { 59 - color: #AA0000; 67 + color: #aa0000; 60 68 } 61 69 62 70 .text-dark_purple { 63 - color: #AA00AA; 71 + color: #aa00aa; 64 72 } 65 73 66 74 .text-gold { 67 - color: #FFAA00; 75 + color: #ffaa00; 68 76 } 69 77 70 78 .text-gray { 71 - color: #AAAAAA; 79 + color: #aaaaaa; 72 80 } 73 81 74 82 .text-dark_gray { ··· 76 84 } 77 85 78 86 .text-blue { 79 - color: #5555FF; 87 + color: #5555ff; 80 88 } 81 89 82 90 .text-green { 83 - color: #55FF55; 91 + color: #55ff55; 84 92 } 85 93 86 94 .text-aqua { 87 - color: #55FFFF; 95 + color: #55ffff; 88 96 } 89 97 90 98 .text-red { 91 - color: #FF5555; 99 + color: #ff5555; 92 100 } 93 101 94 102 .text-light_purple { 95 - color: #FF55FF; 103 + color: #ff55ff; 96 104 } 97 105 98 106 .text-yellow { 99 - color: #FFFF55; 107 + color: #ffff55; 100 108 } 101 109 102 110 .text-white { 103 - color: #FFFFFF; 111 + color: #ffffff; 104 112 } 105 113 106 114 :root { ··· 118 126 119 127 --accent: #4f9a3a; 120 128 --accent-strong: #336f26; 121 - --category-active-border: #275117; 122 - --category-active-surface: #78b65a; 123 - --category-active-ink: #0f2a09; 124 129 125 130 --border: #3b3b3b; 126 131 --button-border: #1f1f1f; ··· 168 173 margin: 0; 169 174 font-family: inherit; 170 175 color: var(--ink); 171 - background: 172 - linear-gradient(45deg, #7f7f7f 25%, #777777 25%, #777777 50%, #7f7f7f 50%, #7f7f7f 75%, #777777 75%, #777777 100%); 176 + background: linear-gradient( 177 + 45deg, 178 + #7f7f7f 25%, 179 + #777777 25%, 180 + #777777 50%, 181 + #7f7f7f 50%, 182 + #7f7f7f 75%, 183 + #777777 75%, 184 + #777777 100% 185 + ); 173 186 background-size: 1rem 1rem; 174 187 } 175 188 ··· 206 219 background: var(--tile-surface); 207 220 } 208 221 209 - :is(.book-badge, .book-icon, .category-link, .tag, .chip, .detail-link, button, .button, .link-reset, input[type="search"], select) { 222 + :is( 223 + .book-badge, 224 + .book-icon, 225 + .category-link, 226 + .tag, 227 + .chip, 228 + .detail-link, 229 + button, 230 + .button, 231 + .link-reset, 232 + input[type="search"], 233 + select 234 + ) { 210 235 border-radius: var(--radius-control); 211 236 } 212 237 ··· 338 363 339 364 .layout { 340 365 margin-top: 1.5rem; 341 - display: grid; 342 - grid-template-columns: 16.25rem 1fr; 343 - gap: 1.5rem; 344 - } 345 - 346 - .sidebar .panel { 347 - margin-bottom: 1.5rem; 348 - padding: 1.125rem; 349 - } 350 - 351 - .sidebar h2 { 352 - margin: 0 0 0.75rem; 353 - font-size: 1.125rem; 354 - } 355 - 356 - .all-link { 357 - display: inline-block; 358 - margin-bottom: 0.75rem; 359 - font-size: 0.8125rem; 360 - } 361 - 362 - .category-list { 363 - margin: 0; 364 - padding: 0; 365 - list-style: none; 366 - display: grid; 367 - gap: 0.5rem; 368 - } 369 - 370 - .category-link { 371 - display: flex; 372 - justify-content: space-between; 373 - padding: 0.5rem 0.625rem; 374 - border: var(--border-thick); 375 - color: var(--ink); 376 - background: #c3c3c3; 377 - } 378 - 379 - .category-link.active { 380 - border-color: var(--category-active-border); 381 - background: var(--category-active-surface); 382 - color: var(--category-active-ink); 383 - } 384 - 385 - .category-link .count { 386 - font-size: 0.75rem; 387 - color: var(--ink-muted); 388 366 } 389 367 390 368 .results-header { ··· 412 390 .book-tile { 413 391 text-align: center; 414 392 padding: 0.75rem 0.625rem; 415 - transition: border 0.15s ease, background 0.15s ease; 393 + transition: 394 + border 0.15s ease, 395 + background 0.15s ease; 416 396 } 417 397 418 398 .book-tile:hover { ··· 434 414 align-items: center; 435 415 justify-content: center; 436 416 box-shadow: var(--bevel-control); 437 - transition: transform 0.15s ease, box-shadow 0.15s ease; 417 + transition: 418 + transform 0.15s ease, 419 + box-shadow 0.15s ease; 438 420 } 439 421 440 422 .book-icon:hover { ··· 521 503 522 504 .pager { 523 505 margin-top: 1.125rem; 506 + margin-bottom: 1.125rem; 524 507 display: flex; 525 508 gap: 0.75rem; 526 509 } ··· 567 550 margin-top: 1.5rem; 568 551 margin-bottom: 1.5rem; 569 552 width: 100%; 570 - max-width: calc((var(--book-sprite-width) * 3) + (var(--book-grid-gap) * 2)); 553 + max-width: calc( 554 + (var(--book-sprite-width) * 3) + (var(--book-grid-gap) * 2) 555 + ); 571 556 margin-inline: auto; 572 557 } 573 558 ··· 659 644 mask-image: var(--mask, var(--book-mask)); 660 645 mask-repeat: no-repeat; 661 646 mask-size: 100% 100%; 662 - animation: glintMove 10s linear infinite, glintHue 10s linear infinite; 647 + animation: 648 + glintMove 10s linear infinite, 649 + glintHue 10s linear infinite; 663 650 } 664 651 665 652 @keyframes glintMove { ··· 683 670 } 684 671 685 672 @media (max-width: 61.25rem) { 686 - .layout { 687 - grid-template-columns: 1fr; 688 - } 689 - 690 - .sidebar .panel { 691 - position: static; 692 - } 693 - 694 673 .search { 695 674 grid-template-columns: 1fr; 696 675 } ··· 706 685 flex-direction: column; 707 686 align-items: flex-start; 708 687 } 709 - } 688 + }
+3 -2
src/web/assets.rs
··· 19 19 include_bytes!("assets/font/bold_italic.woff2"); 20 20 21 21 const CSS_STYLES_TEMPLATE: &str = include_str!("assets/stylesheet.css"); 22 - static CSS_STYLES: LazyLock<String> = 23 - LazyLock::new(|| CSS_STYLES_TEMPLATE.replace("__NARA_ASSET_VERSION__", crate::VERSION)); 22 + static CSS_STYLES: LazyLock<String> = LazyLock::new(|| { 23 + CSS_STYLES_TEMPLATE.replace("__NARA_ASSET_VERSION__", crate::VERSION) 24 + }); 24 25 25 26 const WEBP_MIME_TYPE: &str = "image/webp"; 26 27 const WOFF2_MIME_TYPE: &str = "font/woff2";
+34 -196
src/web/pages.rs
··· 11 11 extract::{Path, Query}, 12 12 response::Redirect, 13 13 }; 14 + use nara_core::book::{Book, BookSource}; 14 15 use serde::Deserialize; 15 16 use smol_str::SmolStr; 16 17 17 - use crate::{ 18 - library::{Library, category_from_location, item::WrittenBookTag}, 19 - web::TextureKind, 20 - }; 18 + use crate::{library::Library, web::TextureKind}; 21 19 22 20 #[derive(Debug, Template, WebTemplate)] 23 21 #[template(path = "index.html", whitespace = "minimize")] ··· 38 36 pub next_offset: usize, 39 37 pub pager_query: String, 40 38 pub books: Vec<BookCard>, 41 - pub categories: Vec<CategoryView>, 42 39 pub authors: Vec<AuthorView>, 43 40 pub git_hash: String, 44 41 } ··· 58 55 pub struct IndexQuery { 59 56 q: Option<String>, 60 57 scope: Option<String>, 61 - category: Option<String>, 62 58 author: Option<String>, 63 59 limit: Option<usize>, 64 60 offset: Option<usize>, ··· 73 69 pub struct QueryView { 74 70 pub q: String, 75 71 pub scope: String, 76 - pub category: String, 77 72 pub author: String, 78 - pub has_category: bool, 79 73 pub has_author: bool, 80 74 pub active: bool, 81 75 } 82 76 83 77 #[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 78 pub struct AuthorView { 93 79 pub name: String, 94 80 pub count: usize, ··· 108 94 pub title: String, 109 95 pub author: String, 110 96 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, 97 + pub source: BookSource, 116 98 pub pages: Vec<PageView>, 117 99 pub page_count: usize, 118 100 } ··· 134 116 135 117 impl HtmlSafe for HtmlText {} 136 118 137 - const DEFAULT_LIMIT: usize = 25; 138 - const MAX_LIMIT: usize = 50; 119 + const DEFAULT_LIMIT: usize = 100; 120 + const MAX_LIMIT: usize = 200; 139 121 const SEARCH_MAX_RESULTS: usize = 200; 140 122 141 123 #[derive(Debug)] 142 124 struct FilterParams { 143 125 query: String, 144 126 scope: &'static str, 145 - category: Option<String>, 146 127 author: Option<String>, 147 128 limit: usize, 148 129 offset: usize, 149 130 author_norm: Option<String>, 150 - category_norm: Option<String>, 151 131 } 152 132 153 133 pub async fn index( ··· 166 146 String, 167 147 usize, 168 148 String, 169 - ) = if !params.query.is_empty() 170 - || params.author.is_some() 171 - || params.category.is_some() 172 - { 149 + ) = if !params.query.is_empty() || params.author.is_some() { 173 150 let filtered = collect_candidates(&library, &params); 174 151 let total = filtered.len(); 175 152 let offset_used = params.offset.min(total); ··· 185 162 format!("Top {0} matches", total) 186 163 }; 187 164 let view_label = if params.query.is_empty() { 188 - filtered_view_label( 189 - params.author.as_deref(), 190 - params.category.as_deref(), 191 - ) 165 + filtered_view_label(params.author.as_deref()) 192 166 } else { 193 167 format!("Search results for \"{0}\"", params.query) 194 168 }; ··· 211 185 ) 212 186 }; 213 187 214 - let categories = build_categories(&library, params.category.as_deref()); 215 188 let authors = build_authors(&library); 216 189 let pager_query = build_pager_query( 217 190 &params.query, 218 191 params.scope, 219 - params.category.as_deref(), 220 192 params.author.as_deref(), 221 193 ); 222 - let has_category = params.category.is_some(); 223 194 let has_author = params.author.is_some(); 224 195 225 196 let active = !params.query.is_empty() 226 - || params.category.is_some() 227 197 || params.author.is_some() 228 198 || params.offset > 0; 229 199 ··· 240 210 let back_href = build_index_href( 241 211 &params.query, 242 212 params.scope, 243 - params.category.as_deref(), 244 213 params.author.as_deref(), 245 214 params.limit, 246 215 offset_used, ··· 257 226 query: QueryView { 258 227 q: params.query.clone(), 259 228 scope: params.scope.to_string(), 260 - category: params.category.clone().unwrap_or_default(), 261 229 author: params.author.clone().unwrap_or_default(), 262 - has_category, 263 230 has_author, 264 231 active, 265 232 }, ··· 276 243 next_offset, 277 244 pager_query, 278 245 books, 279 - categories, 280 246 authors, 281 247 git_hash, 282 248 } ··· 321 287 let back_href = build_index_href( 322 288 &params.query, 323 289 params.scope, 324 - params.category.as_deref(), 325 290 params.author.as_deref(), 326 291 params.limit, 327 292 params.offset, ··· 342 307 fn from_query(query: IndexQuery) -> Self { 343 308 let query_text = query.q.unwrap_or_default(); 344 309 let query_text = query_text.trim().to_string(); 345 - let category = cleaned_param(query.category); 346 310 let author = cleaned_param(query.author); 347 311 Self { 348 312 query: query_text, 349 313 scope: parse_scope(query.scope.as_deref()), 350 314 limit: clamp_limit(query.limit), 351 315 offset: query.offset.unwrap_or(0), 352 - category_norm: category.as_deref().map(normalize_query), 353 316 author_norm: author.as_deref().map(normalize_query), 354 - category, 355 317 author, 356 318 } 357 319 } ··· 360 322 fn collect_candidates<'a>( 361 323 library: &'a Library, 362 324 params: &FilterParams, 363 - ) -> Vec<&'a WrittenBookTag> { 325 + ) -> Vec<&'a Book> { 364 326 if !params.query.is_empty() { 365 - // Search results are narrowed by optional author/category filters. 366 327 return search_books( 367 328 library, 368 329 params.scope, ··· 370 331 SEARCH_MAX_RESULTS, 371 332 ) 372 333 .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 - }) 334 + .filter(|book| matches_author(book, params.author_norm.as_deref())) 381 335 .collect(); 382 336 } 383 337 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 338 if let Some(author_name) = params.author.as_deref() { 393 339 return library.books_by_author(author_name).collect(); 394 340 } 395 - if let Some(category_name) = params.category.as_deref() { 396 - return library.books_in_category(category_name).collect(); 397 - } 398 341 library.all_books().collect() 399 342 } 400 343 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"), 344 + fn filtered_view_label(author: Option<&str>) -> String { 345 + match author { 346 + Some(author) => format!("Author: {0}", author), 347 + None => String::from("Filtered books"), 409 348 } 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 349 } 439 350 440 351 fn build_authors(library: &Library) -> Vec<AuthorView> { 441 352 let mut counts: ahash::AHashMap<SmolStr, (SmolStr, usize)> = 442 353 ahash::AHashMap::new(); 443 354 for book in library.all_books() { 444 - let raw = book.author.trim(); 355 + let raw = book.content.author.trim(); 445 356 if raw.is_empty() { 446 357 continue; 447 358 } ··· 477 388 (group, trimmed.to_lowercase()) 478 389 } 479 390 480 - fn build_pager_query( 481 - q: &str, 482 - scope: &str, 483 - category: Option<&str>, 484 - author: Option<&str>, 485 - ) -> String { 391 + fn build_pager_query(q: &str, scope: &str, author: Option<&str>) -> String { 486 392 let mut parts = Vec::new(); 487 393 if !q.is_empty() { 488 394 parts.push(format!("q={0}", encode_query_component(q))); 489 395 if scope != "all" { 490 396 parts.push(format!("scope={0}", encode_query_component(scope))); 491 397 } 492 - } 493 - if let Some(category) = category { 494 - parts.push(format!("category={0}", encode_query_component(category))); 495 398 } 496 399 if let Some(author) = author { 497 400 parts.push(format!("author={0}", encode_query_component(author))); ··· 506 409 fn build_index_href( 507 410 q: &str, 508 411 scope: &str, 509 - category: Option<&str>, 510 412 author: Option<&str>, 511 413 limit: usize, 512 414 offset: usize, ··· 518 420 parts.push(format!("scope={0}", encode_query_component(scope))); 519 421 } 520 422 } 521 - if let Some(category) = category { 522 - parts.push(format!("category={0}", encode_query_component(category))); 523 - } 524 423 if let Some(author) = author { 525 424 parts.push(format!("author={0}", encode_query_component(author))); 526 425 } ··· 551 450 } 552 451 } 553 452 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 { 453 + fn book_card(book: &Book) -> BookCard { 597 454 let hash_hex = hex::encode(book.hash()); 598 455 let author_href = 599 - format!("/?author={0}", encode_query_component(&book.author)); 456 + format!("/?author={0}", encode_query_component(&book.content.author)); 600 457 601 458 BookCard { 602 - title: book.title.clone(), 603 - author: book.author.clone(), 459 + title: book.content.title.clone(), 460 + author: book.content.author.clone(), 604 461 author_href, 605 462 hash: hash_hex.clone(), 606 463 detail_href: format!("/book/{0}", hash_hex), 607 464 } 608 465 } 609 466 610 - fn book_detail(book: &WrittenBookTag, library: &Library) -> BookDetail { 611 - let meta = book_meta(book, library); 467 + fn book_detail(book: &Book, _library: &Library) -> BookDetail { 468 + let author_href = 469 + format!("/?author={0}", encode_query_component(&book.content.author)); 612 470 let pages = book 471 + .content 613 472 .pages 614 473 .iter() 615 474 .enumerate() 616 475 .map(|(idx, page)| PageView { 617 476 index: idx + 1, 618 - html: HtmlText(page.to_html()), 477 + html: HtmlText(page.to_plain_text()), // TODO(kokiriglade): pass to_html or similar here 619 478 }) 620 479 .collect(); 621 480 622 481 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, 482 + title: book.content.title.clone(), 483 + author: book.content.author.clone(), 484 + author_href, 485 + source: book.metadata.source.clone(), 631 486 pages, 632 - page_count: book.pages.len(), 487 + page_count: book.content.pages.len(), 633 488 } 634 489 } 635 490 ··· 661 516 scope: &str, 662 517 query: &str, 663 518 fetch: usize, 664 - ) -> Vec<&'a WrittenBookTag> { 519 + ) -> Vec<&'a Book> { 665 520 match scope { 666 521 "title" => search_title_relaxed(library, query, fetch), 667 522 "author" => library ··· 686 541 library: &'a Library, 687 542 query: &str, 688 543 fetch: usize, 689 - ) -> Vec<&'a WrittenBookTag> { 544 + ) -> Vec<&'a Book> { 690 545 let needle = normalize_query(query); 691 546 if needle.is_empty() || fetch == 0 { 692 547 return Vec::new(); ··· 696 551 let mut seen: AHashSet<[u8; 20]> = AHashSet::new(); 697 552 698 553 for book in library.all_books() { 699 - let title_norm = normalize_query(&book.title); 554 + let title_norm = normalize_query(&book.content.title); 700 555 if title_norm.contains(&needle) { 701 556 let hash = book.hash(); 702 557 if seen.insert(hash) { ··· 721 576 out 722 577 } 723 578 724 - fn matches_author(book: &WrittenBookTag, author_norm: Option<&str>) -> bool { 579 + fn matches_author(book: &Book, author_norm: Option<&str>) -> bool { 725 580 let Some(author_norm) = author_norm else { 726 581 return true; 727 582 }; 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) 583 + book.content.author.trim().eq_ignore_ascii_case(author_norm) 746 584 } 747 585 748 586 fn parse_hash(hex_str: &str) -> Option<[u8; 20]> {
+2 -2
src/web.rs
··· 8 8 use tokio::net::TcpListener; 9 9 use tower_http::compression::CompressionLayer; 10 10 11 - use crate::{cli::Args, library::Library}; 11 + use crate::{cli::ServeArgs, library::Library}; 12 12 13 13 pub mod api; 14 14 pub mod assets; ··· 32 32 } 33 33 34 34 pub async fn start_webserver( 35 - args: &Args, 35 + args: &ServeArgs, 36 36 library: Library, 37 37 ) -> anyhow::Result<()> { 38 38 let library = Arc::new(Mutex::new(library));
+50 -43
templates/book.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block title %}{{ book.title }} - nara{% endblock %} 1 + {% extends "base.html" %} {% block title %}{{ book.title }} - nara{% endblock %} 4 2 {% block head %} 5 - <link rel="icon" type="image/webp" href="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" /> 3 + <link 4 + rel="icon" 5 + type="image/webp" 6 + href="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" 7 + /> 6 8 <meta property="og:site_name" content="nara" /> 7 9 <meta property="og:type" content="article" /> 8 10 <meta property="og:title" content="{{ book.title }} - nara" /> 9 - <meta property="og:description" content="By {{ book.author }}. {{ book.page_count }} pages." /> 11 + <meta 12 + property="og:description" 13 + content="By {{ book.author }}. {{ book.page_count }} pages." 14 + /> 10 15 <meta name="twitter:card" content="summary_large_image" /> 11 16 <meta name="twitter:title" content="{{ book.title }} - nara" /> 12 - <meta name="twitter:description" content="By {{ book.author }}. {{ book.page_count }} pages." /> 13 - {% endblock %} 14 - 15 - {% block page_class %}page detail{% endblock %} 16 - {% block page_attrs %} style="--book-mask: url('/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}')"{% endblock %} 17 - 18 - {% block body %} 17 + <meta 18 + name="twitter:description" 19 + content="By {{ book.author }}. {{ book.page_count }} pages." 20 + /> 21 + {% endblock %} {% block page_class %}page detail{% endblock %} {% block 22 + page_attrs %} style="--book-mask: url('/assets/image/{{ texture_kind 23 + }}/written_book.webp?v={{ crate::VERSION }}')"{% endblock %} {% block body %} 19 24 <header class="hero detail-hero"> 20 - <div class="brand"> 21 - <span class="enchanted book-badge"> 22 - <img class="item" src="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" alt=""> 23 - </span> 24 - <div> 25 - <p class="eyebrow">Book Detail</p> 26 - <h1 class="font-minecraft">{{ book.title }}</h1> 27 - <p class="meta">by <a href="{{ book.author_href }}">{{ book.author }}</a></p> 28 - <div class="meta-row"> 29 - {% if book.has_category %} 30 - <a class="tag-link" href="{{ book.category_href }}"><span class="tag">{{ book.category }}</span></a> 31 - {% endif %} 32 - {% if book.has_location %} 33 - <span class="tag subtle">{{ book.location }}</span> 34 - {% endif %} 35 - <span class="tag subtle">{{ book.page_count }} pages</span> 36 - </div> 25 + <div class="brand"> 26 + <span class="enchanted book-badge"> 27 + <img 28 + class="item" 29 + src="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" 30 + alt="" 31 + /> 32 + </span> 33 + <div> 34 + <p class="eyebrow">Book Detail</p> 35 + <h1 class="font-minecraft">{{ book.title }}</h1> 36 + <p class="meta"> 37 + by <a href="{{ book.author_href }}">{{ book.author }}</a> 38 + </p> 39 + <div class="meta-row"> 40 + <span class="tag subtle">{{ book.page_count }} pages</span> 41 + </div> 42 + </div> 37 43 </div> 38 - </div> 39 - <div class="detail-actions"> 40 - <a class="button ghost" href="{{ back_href }}">Back to list</a> 41 - </div> 44 + <div class="detail-actions"> 45 + <a class="button ghost" href="{{ back_href }}">Back to list</a> 46 + </div> 42 47 </header> 43 48 44 49 <section class="detail-body"> 45 - <div class="pages"> 46 - {% for page in book.pages %} 47 - <article class="book-page"> 48 - <div class="book-page-sprite"> 49 - <p class="book-page-header font-minecraft">Page {{ page.index }} of {{ book.page_count }}</p> 50 - <div class="book-page-text font-minecraft">{{ page.html }}</div> 51 - </div> 52 - </article> 53 - {% endfor %} 54 - </div> 50 + <div class="pages"> 51 + {% for page in book.pages %} 52 + <article class="book-page"> 53 + <div class="book-page-sprite"> 54 + <p class="book-page-header font-minecraft"> 55 + Page {{ page.index }} of {{ book.page_count }} 56 + </p> 57 + <div class="book-page-text font-minecraft">{{ page.html }}</div> 58 + </div> 59 + </article> 60 + {% endfor %} 61 + </div> 55 62 </section> 56 63 {% endblock %}
+1 -34
templates/index.html
··· 53 53 {% endfor %} 54 54 </select> 55 55 </label> 56 - <label class="field"> 57 - <span>Category filter</span> 58 - <select name="category"> 59 - <option value="" {% if !query.has_category %}selected{% endif %}>All categories</option> 60 - {% for c in categories %} 61 - <option value="{{ c.name }}" {% if query.category==c.name %}selected{% endif %}>{{ c.name }} 62 - </option> 63 - {% endfor %} 64 - </select> 65 - </label> 66 56 <div class="actions"> 67 57 <button type="submit">Search</button> 68 58 {% if query.active %} ··· 72 62 </div> 73 63 </form> 74 64 75 - {% if query.has_category || query.has_author %} 65 + {% if query.has_author %} 76 66 <div class="active-filters"> 77 - {% if query.has_category %} 78 - <span class="chip">Category: {{ query.category }}</span> 79 - {% endif %} 80 - {% if query.has_author %} 81 67 <span class="chip">Author: {{ query.author }}</span> 82 - {% endif %} 83 68 </div> 84 69 {% endif %} 85 70 </header> 86 71 87 72 <section class="layout"> 88 - <aside class="sidebar"> 89 - <div class="panel"> 90 - <h2>Browse Categories</h2> 91 - <a class="all-link" href="/">All books</a> 92 - <ul class="category-list"> 93 - {% for c in categories %} 94 - <li> 95 - <a class="category-link {% if c.active %}active{% endif %}" href="{{ c.href }}"> 96 - <span>{{ c.name }}</span> 97 - <span class="count">{{ c.count }}</span> 98 - </a> 99 - </li> 100 - {% endfor %} 101 - </ul> 102 - </div> 103 - </aside> 104 - 105 73 <section class="results"> 106 74 <div class="results-header"> 107 75 <div> ··· 158 126 {% endif %} 159 127 </nav> 160 128 {% endif %} 161 - </section> 162 129 </section> 163 130 {% endblock %}

History

1 round 0 comments
sign up or login to add to the discussion
kokirigla.de submitted #0
17 commits
expand
feat: split cli
refactor: prepare for sub-projects
chore: update .gitignore
feat: text colors
feat(text): improve a lot
chore: cargo fmt
feat(text): finish component subtypes
test(text): write tests for subtypes
chore(text): write a readme
chore: update main readme
feat(1.12): a lot of stuff
feat(*): a lot more work on abstraction
feat(1.12): more work on world slurping
feat(slurp): a LOT of work, almost done now
feat(slurp,serve): save to and read from BookContainer
feat(serve): yeet categories
chore: appease clippy
expand 0 comments
closed without merging