cross version now wooo
+4
-1
.gitignore
+4
-1
.gitignore
+273
-30
Cargo.lock
+273
-30
Cargo.lock
···
291
]
292
293
[[package]]
294
name = "brotli"
295
version = "8.0.2"
296
source = "registry+https://github.com/rust-lang/crates.io-index"
···
346
version = "1.0.4"
347
source = "registry+https://github.com/rust-lang/crates.io-index"
348
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
349
350
[[package]]
351
name = "chrono"
···
426
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
427
428
[[package]]
429
name = "const-oid"
430
version = "0.10.2"
431
source = "registry+https://github.com/rust-lang/crates.io-index"
···
561
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
562
563
[[package]]
564
name = "equivalent"
565
version = "1.0.2"
566
source = "registry+https://github.com/rust-lang/crates.io-index"
···
577
]
578
579
[[package]]
580
name = "find-msvc-tools"
581
version = "0.1.9"
582
source = "registry+https://github.com/rust-lang/crates.io-index"
···
600
601
[[package]]
602
name = "foldhash"
603
-
version = "0.1.5"
604
source = "registry+https://github.com/rust-lang/crates.io-index"
605
-
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
606
607
[[package]]
608
name = "form_urlencoded"
···
672
673
[[package]]
674
name = "hashbrown"
675
-
version = "0.15.5"
676
source = "registry+https://github.com/rust-lang/crates.io-index"
677
-
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
678
dependencies = [
679
"allocator-api2",
680
"equivalent",
681
"foldhash",
682
]
683
-
684
-
[[package]]
685
-
name = "hashbrown"
686
-
version = "0.16.1"
687
-
source = "registry+https://github.com/rust-lang/crates.io-index"
688
-
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
689
690
[[package]]
691
name = "heck"
···
852
]
853
854
[[package]]
855
name = "is_terminal_polyfill"
856
version = "1.70.2"
857
source = "registry+https://github.com/rust-lang/crates.io-index"
···
896
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
897
898
[[package]]
899
name = "lock_api"
900
version = "0.4.14"
901
source = "registry+https://github.com/rust-lang/crates.io-index"
···
912
913
[[package]]
914
name = "lru"
915
-
version = "0.12.5"
916
source = "registry+https://github.com/rust-lang/crates.io-index"
917
-
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
918
dependencies = [
919
-
"hashbrown 0.15.5",
920
]
921
922
[[package]]
···
960
961
[[package]]
962
name = "nara"
963
-
version = "0.1.0"
964
dependencies = [
965
"ahash",
966
"anyhow",
···
972
"hex",
973
"html-escape",
974
"lru",
975
"serde",
976
"serde_json",
977
"serde_with",
···
986
]
987
988
[[package]]
989
name = "nu-ansi-term"
990
version = "0.50.3"
991
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1143
]
1144
1145
[[package]]
1146
name = "rustversion"
1147
version = "1.0.22"
1148
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1258
1259
[[package]]
1260
name = "serde_with"
1261
-
version = "3.16.1"
1262
source = "registry+https://github.com/rust-lang/crates.io-index"
1263
-
checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7"
1264
dependencies = [
1265
"base64",
1266
"chrono",
···
1277
1278
[[package]]
1279
name = "serde_with_macros"
1280
-
version = "3.16.1"
1281
source = "registry+https://github.com/rust-lang/crates.io-index"
1282
-
checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
1283
dependencies = [
1284
"darling",
1285
"proc-macro2",
···
1328
version = "0.3.8"
1329
source = "registry+https://github.com/rust-lang/crates.io-index"
1330
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
1331
1332
[[package]]
1333
name = "slab"
···
1343
1344
[[package]]
1345
name = "smol_str"
1346
-
version = "0.2.2"
1347
source = "registry+https://github.com/rust-lang/crates.io-index"
1348
-
checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
1349
dependencies = [
1350
-
"serde",
1351
]
1352
1353
[[package]]
···
1384
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1385
1386
[[package]]
1387
name = "thiserror"
1388
version = "2.0.18"
1389
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1614
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1615
1616
[[package]]
1617
name = "valuable"
1618
version = "0.1.1"
1619
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1746
1747
[[package]]
1748
name = "windows-sys"
1749
version = "0.60.2"
1750
source = "registry+https://github.com/rust-lang/crates.io-index"
1751
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
1752
dependencies = [
1753
-
"windows-targets",
1754
]
1755
1756
[[package]]
···
1764
1765
[[package]]
1766
name = "windows-targets"
1767
version = "0.53.5"
1768
source = "registry+https://github.com/rust-lang/crates.io-index"
1769
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
1770
dependencies = [
1771
"windows-link",
1772
-
"windows_aarch64_gnullvm",
1773
-
"windows_aarch64_msvc",
1774
-
"windows_i686_gnu",
1775
-
"windows_i686_gnullvm",
1776
-
"windows_i686_msvc",
1777
-
"windows_x86_64_gnu",
1778
-
"windows_x86_64_gnullvm",
1779
-
"windows_x86_64_msvc",
1780
]
1781
1782
[[package]]
1783
name = "windows_aarch64_gnullvm"
1784
version = "0.53.1"
1785
source = "registry+https://github.com/rust-lang/crates.io-index"
1786
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
1787
1788
[[package]]
1789
name = "windows_aarch64_msvc"
1790
version = "0.53.1"
1791
source = "registry+https://github.com/rust-lang/crates.io-index"
1792
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
1793
1794
[[package]]
1795
name = "windows_i686_gnu"
1796
version = "0.53.1"
1797
source = "registry+https://github.com/rust-lang/crates.io-index"
1798
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
1799
1800
[[package]]
1801
name = "windows_i686_gnullvm"
1802
version = "0.53.1"
1803
source = "registry+https://github.com/rust-lang/crates.io-index"
1804
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
1805
1806
[[package]]
1807
name = "windows_i686_msvc"
···
1811
1812
[[package]]
1813
name = "windows_x86_64_gnu"
1814
version = "0.53.1"
1815
source = "registry+https://github.com/rust-lang/crates.io-index"
1816
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
1817
1818
[[package]]
1819
name = "windows_x86_64_gnullvm"
1820
version = "0.53.1"
1821
source = "registry+https://github.com/rust-lang/crates.io-index"
1822
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
1823
1824
[[package]]
1825
name = "windows_x86_64_msvc"
···
291
]
292
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]]
303
name = "brotli"
304
version = "8.0.2"
305
source = "registry+https://github.com/rust-lang/crates.io-index"
···
355
version = "1.0.4"
356
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
364
365
[[package]]
366
name = "chrono"
···
441
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
442
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]]
456
name = "const-oid"
457
version = "0.10.2"
458
source = "registry+https://github.com/rust-lang/crates.io-index"
···
588
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
589
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]]
597
name = "equivalent"
598
version = "1.0.2"
599
source = "registry+https://github.com/rust-lang/crates.io-index"
···
610
]
611
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]]
619
name = "find-msvc-tools"
620
version = "0.1.9"
621
source = "registry+https://github.com/rust-lang/crates.io-index"
···
639
640
[[package]]
641
name = "foldhash"
642
+
version = "0.2.0"
643
source = "registry+https://github.com/rust-lang/crates.io-index"
644
+
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
645
646
[[package]]
647
name = "form_urlencoded"
···
711
712
[[package]]
713
name = "hashbrown"
714
+
version = "0.16.1"
715
source = "registry+https://github.com/rust-lang/crates.io-index"
716
+
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
717
dependencies = [
718
"allocator-api2",
719
"equivalent",
720
"foldhash",
721
]
722
723
[[package]]
724
name = "heck"
···
885
]
886
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]]
900
name = "is_terminal_polyfill"
901
version = "1.70.2"
902
source = "registry+https://github.com/rust-lang/crates.io-index"
···
941
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
942
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]]
950
name = "lock_api"
951
version = "0.4.14"
952
source = "registry+https://github.com/rust-lang/crates.io-index"
···
963
964
[[package]]
965
name = "lru"
966
+
version = "0.16.3"
967
source = "registry+https://github.com/rust-lang/crates.io-index"
968
+
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
969
dependencies = [
970
+
"hashbrown 0.16.1",
971
]
972
973
[[package]]
···
1011
1012
[[package]]
1013
name = "nara"
1014
+
version = "0.2.0"
1015
dependencies = [
1016
"ahash",
1017
"anyhow",
···
1023
"hex",
1024
"html-escape",
1025
"lru",
1026
+
"nara_core",
1027
+
"nara_slurper_1_12_infinity",
1028
+
"nara_slurper_1_12_world",
1029
"serde",
1030
"serde_json",
1031
"serde_with",
···
1040
]
1041
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]]
1115
name = "nu-ansi-term"
1116
version = "0.50.3"
1117
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1269
]
1270
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]]
1285
name = "rustversion"
1286
version = "1.0.22"
1287
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1397
1398
[[package]]
1399
name = "serde_with"
1400
+
version = "3.17.0"
1401
source = "registry+https://github.com/rust-lang/crates.io-index"
1402
+
checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
1403
dependencies = [
1404
"base64",
1405
"chrono",
···
1416
1417
[[package]]
1418
name = "serde_with_macros"
1419
+
version = "3.17.0"
1420
source = "registry+https://github.com/rust-lang/crates.io-index"
1421
+
checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
1422
dependencies = [
1423
"darling",
1424
"proc-macro2",
···
1467
version = "0.3.8"
1468
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
1476
1477
[[package]]
1478
name = "slab"
···
1488
1489
[[package]]
1490
name = "smol_str"
1491
+
version = "0.3.5"
1492
source = "registry+https://github.com/rust-lang/crates.io-index"
1493
+
checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17"
1494
dependencies = [
1495
+
"borsh",
1496
+
"serde_core",
1497
]
1498
1499
[[package]]
···
1530
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1531
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]]
1546
name = "thiserror"
1547
version = "2.0.18"
1548
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1773
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1774
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]]
1787
name = "valuable"
1788
version = "0.1.1"
1789
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1916
1917
[[package]]
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"
1928
version = "0.60.2"
1929
source = "registry+https://github.com/rust-lang/crates.io-index"
1930
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
1931
dependencies = [
1932
+
"windows-targets 0.53.5",
1933
]
1934
1935
[[package]]
···
1943
1944
[[package]]
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"
1962
version = "0.53.5"
1963
source = "registry+https://github.com/rust-lang/crates.io-index"
1964
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
1965
dependencies = [
1966
"windows-link",
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",
1975
]
1976
1977
[[package]]
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"
1985
version = "0.53.1"
1986
source = "registry+https://github.com/rust-lang/crates.io-index"
1987
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
1988
1989
[[package]]
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"
1997
version = "0.53.1"
1998
source = "registry+https://github.com/rust-lang/crates.io-index"
1999
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
2000
2001
[[package]]
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"
2009
version = "0.53.1"
2010
source = "registry+https://github.com/rust-lang/crates.io-index"
2011
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
2012
2013
[[package]]
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"
2021
version = "0.53.1"
2022
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
2030
2031
[[package]]
2032
name = "windows_i686_msvc"
···
2036
2037
[[package]]
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"
2045
version = "0.53.1"
2046
source = "registry+https://github.com/rust-lang/crates.io-index"
2047
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
2048
2049
[[package]]
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"
2057
version = "0.53.1"
2058
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
2066
2067
[[package]]
2068
name = "windows_x86_64_msvc"
+66
-23
Cargo.toml
+66
-23
Cargo.toml
···
1
[package]
2
name = "nara"
3
-
version = "0.1.0"
4
-
edition = "2024"
5
6
[dependencies]
7
-
ahash = "0.8"
8
-
anyhow = "1.0"
9
-
askama = "0.15"
10
-
askama_web = { version = "0.15", features = ["axum-0.8", "tracing-0.1"] }
11
-
axum = "0.8"
12
-
clap = { version = "4.5", features = ["derive"] }
13
-
crab_nbt = { version = "0.2", features = ["serde"] }
14
-
hex = "0.4"
15
-
html-escape = "0.2"
16
-
lru = "0.12"
17
-
serde = { version = "1.0", features = ["derive"] }
18
-
serde_json = "1.0"
19
-
serde_with = "3.16"
20
-
sha1 = "0.11.0-rc.5"
21
-
smol_str = "0.2"
22
-
strsim = "0.11"
23
-
thiserror = "2.0"
24
-
tokio = { version = "1.0", features = ["full"] }
25
-
tower-http = { version = "0.6", features = ["compression-full"] }
26
-
tracing = "0.1"
27
-
tracing-subscriber = "0.3"
···
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
+
41
[package]
42
name = "nara"
43
+
version.workspace = true
44
+
edition.workspace = true
45
46
[dependencies]
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
-75
README.md
···
1
# nara
2
3
-
A web-based book archival/viewing tool for Minecraft 1.12.2 data exported via Infinity Item Editor.
4
-
5
-
## Prerequisites
6
-
7
-
- [Rust toolchain][rust] (to compile)
8
-
- [Minecraft: Java Edition][mc] (1.12.2)
9
-
- [Minecraft Forge for 1.12.2][forge]
10
-
- [Infinity Item Editor][iie] mod
11
-
12
-
## Usage
13
-
14
-
### 1. Build
15
-
16
-
```sh
17
-
cargo build --release
18
-
````
19
-
20
-
The binary will be in `target/release/` (`nara` or `nara.exe`).
21
-
22
-
### 2. Export books from Minecraft
23
-
24
-
1. Install the [Infinity Item Editor][iie] mod for Minecraft 1.12.2.
25
-
26
-
2. In Minecraft (with the mod installed), put your written books into Shulker Boxes (the colour of the Shulker Boxes does not matter).
27
-
28
-
3. Rename each shulker box in an anvil as:
29
-
30
-
`CATEGORY_NAME ID`
31
-
32
-
Example for a category named “Fiction”:
33
-
34
-
`Fiction 1`, `Fiction 2`, `Fiction 3`
35
-
36
-
4. Save each shulker box using Infinity Item Editor (default keybind: `G`, configurable in Controls).
37
-
38
-
Infinity Item Editor writes exports into `realm.nbt`.
39
-
40
-
### 3. Run
41
-
42
-
1. Make a new directory to hold both the app and the exported data.
43
-
2. Copy the `nara` binary into it.
44
-
3. Copy `realm.nbt` into it from your Minecraft directory:
45
-
46
-
* **Windows:** `%AppData%\.minecraft\infinity-data\realm.nbt`
47
-
* **Linux:** `~/.minecraft/infinity-data/realm.nbt`
48
-
* **macOS:** `~/Library/Application Support/minecraft/infinity-data/realm.nbt`
49
-
50
-
**macOS/Linux**
51
-
52
-
```sh
53
-
./nara
54
-
```
55
-
56
-
**Windows**
57
-
58
-
```bat
59
-
nara.exe
60
-
```
61
-
62
-
nara will print a local URL in the terminal - open it in your browser.
63
-
64
-
## Customization
65
-
66
-
All customization is via CLI flags:
67
-
68
-
```sh
69
-
./nara --help
70
-
```
71
-
72
-
Open an issue if anything is unclear.
73
-
74
-
[rust]: https://rustup.rs/ "rustup.rs - the Rust toolchain installer"
75
-
[mc]: https://minecraft.net/ "Minecraft"
76
-
[forge]: https://files.minecraftforge.net/net/minecraftforge/forge/index_1.12.2.html "Downloads for Minecraft Forge for 1.12.2"
77
-
[iie]: https://modrinth.com/mod/infinity-item-editor/version/0.15 "modrinth.com | 0.15 - Infinity Item Editor"
+8
-4
justfile
+8
-4
justfile
+17
nara_core/Cargo.toml
+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
+5
nara_core/README.md
+154
nara_core/src/book.rs
+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
+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
+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(¤t) {
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>(¤t) {
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>(¤t) {
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>(¤t) {
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
+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
+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
+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
+
}
+7
nara_core/tests/fixtures/nbt_block.json
+7
nara_core/tests/fixtures/nbt_block.json
+7
nara_core/tests/fixtures/nbt_entity.json
+7
nara_core/tests/fixtures/nbt_entity.json
+7
nara_core/tests/fixtures/nbt_storage.json
+7
nara_core/tests/fixtures/nbt_storage.json
+5
nara_core/tests/fixtures/object_atlas.json
+5
nara_core/tests/fixtures/object_atlas.json
+4
nara_core/tests/fixtures/object_player.json
+4
nara_core/tests/fixtures/object_player.json
+18
nara_core/tests/fixtures/object_player_profile.json
+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
+6
nara_core/tests/fixtures/score.json
+4
nara_core/tests/fixtures/selector.json
+4
nara_core/tests/fixtures/selector.json
+1
nara_core/tests/fixtures/text_plain.json
+1
nara_core/tests/fixtures/text_plain.json
···
···
1
+
"Hello, World!"
+10
nara_core/tests/fixtures/translation.json
+10
nara_core/tests/fixtures/translation.json
+9
nara_core/tests/keybind.rs
+9
nara_core/tests/keybind.rs
+23
nara_core/tests/nbt.rs
+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
+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
+9
nara_core/tests/score.rs
+9
nara_core/tests/selector.rs
+9
nara_core/tests/selector.rs
+29
nara_core/tests/snapshots/keybind__keybind.snap
+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
+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
+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
+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
+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
+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
+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
+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
+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
+7
nara_core/tests/snapshots/text__plain.snap
+29
nara_core/tests/snapshots/text__tagged.snap
+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
+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
+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
+9
nara_core/tests/translation.rs
+11
nara_io/Cargo.toml
+11
nara_io/Cargo.toml
+86
nara_io/src/lib.rs
+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
+10
nara_mcr/Cargo.toml
+131
nara_mcr/src/lib.rs
+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
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
+11
nara_slurper_1_12_core/Cargo.toml
+3
nara_slurper_1_12_core/README.md
+3
nara_slurper_1_12_core/README.md
+70
nara_slurper_1_12_core/src/chunk.rs
+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
+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
+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
+10
nara_slurper_1_12_infinity/Cargo.toml
+5
nara_slurper_1_12_infinity/README.md
+5
nara_slurper_1_12_infinity/README.md
+40
nara_slurper_1_12_infinity/src/lib.rs
+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
+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
+9
nara_slurper_1_12_world/README.md
+218
nara_slurper_1_12_world/src/lib.rs
+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(®ion_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
+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
+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
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
+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
+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
+69
-5
src/cli.rs
···
1
use std::path::PathBuf;
2
3
-
use clap::{ArgAction, Parser};
4
5
use crate::web::TextureKind;
6
7
#[derive(Parser, Debug)]
8
#[command(version, about, long_about = None)]
9
-
pub struct Args {
10
/// The path to the `realm.nbt` file created by Infinity Item Editor.
11
#[arg(short = 'r', long = "realm", default_value = "realm.nbt")]
12
pub realm_path: PathBuf,
13
14
-
/// Whether to warn about duplicate books being found during initial indexing.
15
-
#[arg(short = 'd', long, default_value_t = true, action = ArgAction::Set)]
16
-
pub warn_duplicates: bool,
17
/// Whether to warn about empty/whitespace books being skipped during indexing.
18
#[arg(short = 'e', long, default_value_t = true, action = ArgAction::Set)]
19
pub warn_empty: bool,
···
30
/// The score threshold for fuzzy finding book authors.
31
#[arg(short = 'A', long, default_value_t = 0.82)]
32
pub author_threshold: f64,
33
34
#[arg(short = 's', long = "dont-start-webserver", action = ArgAction::SetFalse)]
35
pub start_webserver: bool,
···
1
use std::path::PathBuf;
2
3
+
use clap::{ArgAction, Args, Parser, Subcommand};
4
5
use crate::web::TextureKind;
6
7
#[derive(Parser, Debug)]
8
#[command(version, about, long_about = None)]
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
+
35
/// The path to the `realm.nbt` file created by Infinity Item Editor.
36
#[arg(short = 'r', long = "realm", default_value = "realm.nbt")]
37
pub realm_path: PathBuf,
38
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 {
73
/// Whether to warn about empty/whitespace books being skipped during indexing.
74
#[arg(short = 'e', long, default_value_t = true, action = ArgAction::Set)]
75
pub warn_empty: bool,
···
86
/// The score threshold for fuzzy finding book authors.
87
#[arg(short = 'A', long, default_value_t = 0.82)]
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,
97
98
#[arg(short = 's', long = "dont-start-webserver", action = ArgAction::SetFalse)]
99
pub start_webserver: bool,
-131
src/library/item.rs
-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
-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(¤t) {
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>(¤t) {
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>(¤t) {
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>(¤t) {
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
+45
-244
src/library.rs
···
1
-
use std::time::{SystemTime, SystemTimeError};
2
-
3
use ahash::{AHashMap, AHashSet};
4
use lru::LruCache;
5
use smol_str::SmolStr;
6
use std::{cell::RefCell, num::NonZeroUsize};
7
use strsim::jaro_winkler;
8
9
-
use serde::{Deserialize, Serialize};
10
-
11
-
/// Item model types parsed from realm data.
12
-
pub mod item;
13
-
pub mod text;
14
-
15
-
use item::{ItemStack, WrittenBookTag};
16
-
use text::{Component, normalize_page, scrub_component, scrub_unwanted_glyphs};
17
-
18
-
pub type BookHash = [u8; 20];
19
pub type BookId = usize;
20
21
-
/// Raw realm export data (top-level list of items).
22
-
#[derive(Debug, Serialize, Deserialize)]
23
-
pub struct Realm {
24
-
pub realm: Vec<ItemStack>,
25
-
pub realm_version: String,
26
-
}
27
-
28
/// In-memory index of all books with lookup and fuzzy search helpers.
29
#[derive(Debug)]
30
pub struct Library {
31
-
books: Vec<WrittenBookTag>,
32
33
by_hash: AHashMap<BookHash, BookId>,
34
-
location_by_hash: AHashMap<BookHash, SmolStr>,
35
-
by_category: AHashMap<SmolStr, Vec<BookId>>,
36
by_author_lc: AHashMap<SmolStr, Vec<BookId>>,
37
38
// normalized blobs for scoring (same index as `books`)
···
50
title_threshold: f64,
51
52
cache_books_by_author: RefCell<LruCache<SmolStr, Vec<BookId>>>,
53
-
cache_books_in_category: RefCell<LruCache<SmolStr, Vec<BookId>>>,
54
cache_fuzzy_title: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
55
cache_fuzzy_author: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
56
cache_fuzzy_contents: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
57
cache_fuzzy_all: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
58
59
-
duplicate_books_filtered: u16,
60
empty_books_filtered: u16,
61
}
62
···
67
limit: usize,
68
}
69
70
-
#[derive(Debug, thiserror::Error)]
71
-
pub enum LibraryError {
72
-
#[error("Book has no location: {0:?}")]
73
-
MissingLocation(WrittenBookTag),
74
-
#[error("Unsupported realm version '{0}'")]
75
-
UnsupportedRealmVersion(String),
76
-
#[error(transparent)]
77
-
SystemTimeError(#[from] SystemTimeError),
78
-
}
79
-
80
-
pub type Result<T> = std::result::Result<T, LibraryError>;
81
-
82
impl Library {
83
-
/// Builds a library index from a realm export.
84
pub fn new(
85
-
realm: Realm,
86
content_threshold: f64,
87
-
author_threshold: f64,
88
title_threshold: f64,
89
-
warn_duplicates: bool,
90
-
warn_empty: bool,
91
-
filter_empty_books: bool,
92
-
) -> Result<Library> {
93
-
if realm.realm_version != "0.2" {
94
-
return Err(LibraryError::UnsupportedRealmVersion(
95
-
realm.realm_version,
96
-
));
97
-
}
98
-
99
-
let start = SystemTime::now();
100
-
let mut library = Library {
101
books: Vec::new(),
102
by_hash: AHashMap::new(),
103
-
location_by_hash: AHashMap::new(),
104
-
by_category: AHashMap::new(),
105
by_author_lc: AHashMap::new(),
106
norm_title: Vec::new(),
107
norm_author: Vec::new(),
···
110
tri_author: AHashMap::new(),
111
tri_contents: AHashMap::new(),
112
content_threshold,
113
-
author_threshold,
114
title_threshold,
115
cache_books_by_author: RefCell::new(new_lru(CACHE_BY_AUTHOR_CAP)),
116
-
cache_books_in_category: RefCell::new(new_lru(
117
-
CACHE_BY_CATEGORY_CAP,
118
-
)),
119
cache_fuzzy_title: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
120
cache_fuzzy_author: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
121
cache_fuzzy_contents: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
122
cache_fuzzy_all: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
123
-
duplicate_books_filtered: 0,
124
empty_books_filtered: 0,
125
-
};
126
-
127
-
fn traverse_items(
128
-
items: Vec<ItemStack>,
129
-
current_location: Option<SmolStr>,
130
-
library: &mut Library,
131
-
warn_duplicates: bool,
132
-
warn_empty: bool,
133
-
filter_empty_books: bool,
134
-
) -> Result<()> {
135
-
for item in items {
136
-
match item {
137
-
ItemStack::WrittenBook { tag, .. } => {
138
-
library.add_book(
139
-
tag,
140
-
current_location.clone(),
141
-
warn_duplicates,
142
-
warn_empty,
143
-
filter_empty_books,
144
-
)?;
145
-
}
146
-
ItemStack::ShulkerBox { tag, .. } => {
147
-
let shulker_location = tag
148
-
.display
149
-
.as_ref()
150
-
.and_then(|d| d.name.as_deref())
151
-
.map(SmolStr::new);
152
-
153
-
let next_location = shulker_location
154
-
.or_else(|| current_location.clone());
155
-
traverse_items(
156
-
tag.block_entity.items,
157
-
next_location,
158
-
library,
159
-
warn_duplicates,
160
-
warn_empty,
161
-
filter_empty_books,
162
-
)?;
163
-
}
164
-
}
165
-
}
166
-
Ok(())
167
}
168
-
169
-
traverse_items(
170
-
realm.realm,
171
-
None,
172
-
&mut library,
173
-
warn_duplicates,
174
-
warn_empty,
175
-
filter_empty_books,
176
-
)?;
177
-
178
-
let elapsed_ms = SystemTime::now().duration_since(start)?.as_nanos()
179
-
as f64
180
-
/ 1_000_000.0;
181
-
tracing::info!(
182
-
"Indexed {0} books in {1} categories in {2}ms (filtered {3} duplicates, {4} empty)",
183
-
library.books.len(),
184
-
library.by_category.keys().len(),
185
-
elapsed_ms,
186
-
library.duplicate_books_filtered,
187
-
library.empty_books_filtered,
188
-
);
189
-
Ok(library)
190
}
191
192
-
/// Inserts a book and updates all indices and caches.
193
-
fn add_book(
194
&mut self,
195
-
mut book: WrittenBookTag,
196
-
location: Option<SmolStr>,
197
-
warn_duplicates: bool,
198
warn_empty: bool,
199
filter_empty_books: bool,
200
-
) -> Result<()> {
201
-
book.title = scrub_unwanted_glyphs(&book.title);
202
-
book.author = scrub_unwanted_glyphs(&book.author);
203
-
for page in &mut book.pages {
204
-
scrub_component(page);
205
-
}
206
if filter_empty_books && book_plain_text_empty(&book) {
207
if warn_empty {
208
tracing::warn!(
209
-
"Skipping empty book in location {0:?}: {1} by {2}",
210
-
location,
211
-
book.title,
212
-
book.author
213
);
214
}
215
self.empty_books_filtered += 1;
216
-
return Ok(());
217
-
}
218
-
219
-
let Some(location) = location else {
220
-
return Err(LibraryError::MissingLocation(book));
221
-
};
222
-
223
-
let Some(category) = category_from_location(&location) else {
224
-
return Err(LibraryError::MissingLocation(book));
225
};
226
227
let h = book.hash();
228
if self.by_hash.contains_key(&h) {
229
-
if warn_duplicates {
230
-
let existing_book_location =
231
-
self.location_by_hash.get(&h).expect("book to exist");
232
-
tracing::warn!(
233
-
"Duplicate book in location {0:?}: {1} by {2} [already in {3}]",
234
-
location,
235
-
book.title,
236
-
book.author,
237
-
existing_book_location
238
-
);
239
-
}
240
-
self.duplicate_books_filtered += 1;
241
-
return Ok(());
242
}
243
244
let id = self.books.len();
245
self.books.push(book);
246
247
// indices...
248
self.by_hash.insert(h, id);
249
-
self.by_category
250
-
.entry(category.clone())
251
-
.or_default()
252
-
.push(id);
253
-
self.location_by_hash.insert(h, location);
254
255
-
let author_lc = SmolStr::new(normalize(&self.books[id].author));
256
if !author_lc.is_empty() {
257
self.by_author_lc.entry(author_lc).or_default().push(id);
258
}
259
260
// normalized blobs (for scoring)
261
-
self.norm_title.push(normalize(&self.books[id].title));
262
-
self.norm_author.push(normalize(&self.books[id].author));
263
self.norm_contents
264
-
.push(normalize_contents(&self.books[id].pages));
265
266
// candidate-generation indices
267
index_trigrams(&mut self.tri_title, id, &self.norm_title[id]);
···
269
index_trigrams(&mut self.tri_contents, id, &self.norm_contents[id]);
270
271
self.cache_books_by_author.borrow_mut().clear();
272
-
self.cache_books_in_category.borrow_mut().clear();
273
self.cache_fuzzy_title.borrow_mut().clear();
274
self.cache_fuzzy_author.borrow_mut().clear();
275
self.cache_fuzzy_contents.borrow_mut().clear();
276
self.cache_fuzzy_all.borrow_mut().clear();
277
-
278
-
Ok(())
279
}
280
281
/// Looks up a book by its content hash.
282
#[inline]
283
-
pub fn book_by_hash(&self, hash: BookHash) -> Option<&WrittenBookTag> {
284
self.by_hash.get(&hash).map(|&id| &self.books[id])
285
}
286
···
289
pub fn books_by_author<'a>(
290
&'a self,
291
author: &str,
292
-
) -> impl Iterator<Item = &'a WrittenBookTag> + 'a {
293
let key = SmolStr::new(normalize(author));
294
let ids = if key.is_empty() {
295
Vec::new()
···
308
ids.into_iter().map(|id| &self.books[id])
309
}
310
311
-
/// Lists books by category derived from location strings.
312
-
#[inline]
313
-
pub fn books_in_category<'a>(
314
-
&'a self,
315
-
category: &str,
316
-
) -> impl Iterator<Item = &'a WrittenBookTag> + 'a {
317
-
let key = SmolStr::new(category);
318
-
let ids = if key.is_empty() {
319
-
Vec::new()
320
-
} else if let Some(ids) =
321
-
self.cache_books_in_category.borrow_mut().get(&key).cloned()
322
-
{
323
-
ids
324
-
} else {
325
-
let ids = self.by_category.get(&key).cloned().unwrap_or_default();
326
-
self.cache_books_in_category
327
-
.borrow_mut()
328
-
.put(key.clone(), ids.clone());
329
-
ids
330
-
};
331
-
332
-
ids.into_iter().map(|id| &self.books[id])
333
-
}
334
-
335
/// Fuzzy search over normalized titles.
336
-
pub fn fuzzy_title(
337
-
&self,
338
-
query: &str,
339
-
limit: usize,
340
-
) -> Vec<(&WrittenBookTag, f64)> {
341
let key = SmolStr::new(normalize(query));
342
if key.is_empty() || limit == 0 {
343
return Vec::new();
···
373
}
374
375
/// Fuzzy search over normalized author names.
376
-
pub fn fuzzy_author(
377
-
&self,
378
-
query: &str,
379
-
limit: usize,
380
-
) -> Vec<(&WrittenBookTag, f64)> {
381
let key = SmolStr::new(normalize(query));
382
if key.is_empty() || limit == 0 {
383
return Vec::new();
···
420
&self,
421
query: &str,
422
limit: usize,
423
-
) -> Vec<(&WrittenBookTag, f64)> {
424
let key = SmolStr::new(normalize(query));
425
if key.is_empty() || limit == 0 {
426
return Vec::new();
···
459
}
460
461
/// Combined fuzzy search (title + author + contents).
462
-
pub fn fuzzy(
463
-
&self,
464
-
query: &str,
465
-
limit: usize,
466
-
) -> Vec<(&WrittenBookTag, f64)> {
467
let key = SmolStr::new(normalize(query));
468
if key.is_empty() || limit == 0 {
469
return Vec::new();
···
540
541
/// Returns a list of all books in the library.
542
#[inline]
543
-
pub fn all_books<'a>(
544
-
&'a self,
545
-
) -> impl Iterator<Item = &'a WrittenBookTag> + 'a {
546
self.books.iter()
547
}
548
549
-
/// Returns all categories with their book counts.
550
#[inline]
551
-
pub fn categories(&self) -> Vec<(SmolStr, usize)> {
552
-
self.by_category
553
-
.iter()
554
-
.map(|(k, v)| (k.clone(), v.len()))
555
-
.collect()
556
-
}
557
-
558
-
/// Returns the location string for a book hash, if present.
559
-
#[inline]
560
-
pub fn location_for_hash(&self, hash: &BookHash) -> Option<&SmolStr> {
561
-
self.location_by_hash.get(hash)
562
}
563
}
564
···
568
s.to_lowercase()
569
}
570
571
-
fn book_plain_text_empty(book: &WrittenBookTag) -> bool {
572
-
book.pages.is_empty()
573
|| book
574
.pages
575
.iter()
576
-
.all(|page| normalize_page(page).trim().is_empty())
577
}
578
579
const CACHE_BY_AUTHOR_CAP: usize = 1024;
580
-
const CACHE_BY_CATEGORY_CAP: usize = 1024;
581
const CACHE_FUZZY_CAP: usize = 256;
582
583
/// Helper to build LRU caches with non-zero capacity.
584
fn new_lru<K: std::hash::Hash + Eq, V>(cap: usize) -> LruCache<K, V> {
585
LruCache::new(NonZeroUsize::new(cap).expect("cache cap must be > 0"))
586
-
}
587
-
588
-
/// Extracts category prefix from a location string.
589
-
pub(crate) fn category_from_location(location: &str) -> Option<SmolStr> {
590
-
let first_digit = location.find(|c: char| c.is_ascii_digit())?;
591
-
let category = location[..first_digit].trim_end();
592
-
if category.is_empty() {
593
-
None
594
-
} else {
595
-
Some(SmolStr::new(category))
596
-
}
597
}
598
599
const MAX_CONTENT_INDEX_CHARS: usize = 16_384;
···
784
fn fuzzy_rank_contents(
785
query: &str,
786
norm_field: &[String],
787
-
tri_index: &ahash::AHashMap<u32, Vec<BookId>>,
788
limit: usize,
789
score_threshold: f64,
790
) -> Vec<(BookId, f64)> {
···
1
use ahash::{AHashMap, AHashSet};
2
use lru::LruCache;
3
+
use nara_core::{
4
+
book::{Book, BookHash, BookSource},
5
+
component::Component,
6
+
};
7
use smol_str::SmolStr;
8
use std::{cell::RefCell, num::NonZeroUsize};
9
use strsim::jaro_winkler;
10
11
pub type BookId = usize;
12
13
/// In-memory index of all books with lookup and fuzzy search helpers.
14
#[derive(Debug)]
15
pub struct Library {
16
+
books: Vec<Book>,
17
18
by_hash: AHashMap<BookHash, BookId>,
19
+
source_by_hash: AHashMap<BookHash, BookSource>,
20
by_author_lc: AHashMap<SmolStr, Vec<BookId>>,
21
22
// normalized blobs for scoring (same index as `books`)
···
34
title_threshold: f64,
35
36
cache_books_by_author: RefCell<LruCache<SmolStr, Vec<BookId>>>,
37
cache_fuzzy_title: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
38
cache_fuzzy_author: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
39
cache_fuzzy_contents: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
40
cache_fuzzy_all: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
41
42
empty_books_filtered: u16,
43
}
44
···
49
limit: usize,
50
}
51
52
impl Library {
53
pub fn new(
54
content_threshold: f64,
55
title_threshold: f64,
56
+
author_threshold: f64,
57
+
) -> Self {
58
+
Self {
59
books: Vec::new(),
60
by_hash: AHashMap::new(),
61
+
source_by_hash: AHashMap::new(),
62
by_author_lc: AHashMap::new(),
63
norm_title: Vec::new(),
64
norm_author: Vec::new(),
···
67
tri_author: AHashMap::new(),
68
tri_contents: AHashMap::new(),
69
content_threshold,
70
title_threshold,
71
+
author_threshold,
72
cache_books_by_author: RefCell::new(new_lru(CACHE_BY_AUTHOR_CAP)),
73
cache_fuzzy_title: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
74
cache_fuzzy_author: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
75
cache_fuzzy_contents: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
76
cache_fuzzy_all: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
77
empty_books_filtered: 0,
78
}
79
}
80
81
+
/// Inserts a book
82
+
pub fn add_book(
83
&mut self,
84
+
book: Book,
85
warn_empty: bool,
86
filter_empty_books: bool,
87
+
) {
88
if filter_empty_books && book_plain_text_empty(&book) {
89
if warn_empty {
90
tracing::warn!(
91
+
"Skipping empty book with source {0:?}: {1} by {2}",
92
+
book.metadata.source,
93
+
book.content.title,
94
+
book.content.author
95
);
96
}
97
self.empty_books_filtered += 1;
98
+
return;
99
};
100
101
let h = book.hash();
102
if self.by_hash.contains_key(&h) {
103
+
return;
104
}
105
106
let id = self.books.len();
107
+
108
+
let source = book.metadata.source.clone();
109
self.books.push(book);
110
111
// indices...
112
self.by_hash.insert(h, id);
113
+
self.source_by_hash.insert(h, source);
114
115
+
let author_lc = SmolStr::new(normalize(&self.books[id].content.author));
116
if !author_lc.is_empty() {
117
self.by_author_lc.entry(author_lc).or_default().push(id);
118
}
119
120
// normalized blobs (for scoring)
121
+
self.norm_title
122
+
.push(normalize(&self.books[id].content.title));
123
+
self.norm_author
124
+
.push(normalize(&self.books[id].content.author));
125
self.norm_contents
126
+
.push(normalize_contents(&self.books[id].content.pages));
127
128
// candidate-generation indices
129
index_trigrams(&mut self.tri_title, id, &self.norm_title[id]);
···
131
index_trigrams(&mut self.tri_contents, id, &self.norm_contents[id]);
132
133
self.cache_books_by_author.borrow_mut().clear();
134
self.cache_fuzzy_title.borrow_mut().clear();
135
self.cache_fuzzy_author.borrow_mut().clear();
136
self.cache_fuzzy_contents.borrow_mut().clear();
137
self.cache_fuzzy_all.borrow_mut().clear();
138
}
139
140
/// Looks up a book by its content hash.
141
#[inline]
142
+
pub fn book_by_hash(&self, hash: BookHash) -> Option<&Book> {
143
self.by_hash.get(&hash).map(|&id| &self.books[id])
144
}
145
···
148
pub fn books_by_author<'a>(
149
&'a self,
150
author: &str,
151
+
) -> impl Iterator<Item = &'a Book> + 'a {
152
let key = SmolStr::new(normalize(author));
153
let ids = if key.is_empty() {
154
Vec::new()
···
167
ids.into_iter().map(|id| &self.books[id])
168
}
169
170
/// Fuzzy search over normalized titles.
171
+
pub fn fuzzy_title(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> {
172
let key = SmolStr::new(normalize(query));
173
if key.is_empty() || limit == 0 {
174
return Vec::new();
···
204
}
205
206
/// Fuzzy search over normalized author names.
207
+
pub fn fuzzy_author(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> {
208
let key = SmolStr::new(normalize(query));
209
if key.is_empty() || limit == 0 {
210
return Vec::new();
···
247
&self,
248
query: &str,
249
limit: usize,
250
+
) -> Vec<(&Book, f64)> {
251
let key = SmolStr::new(normalize(query));
252
if key.is_empty() || limit == 0 {
253
return Vec::new();
···
286
}
287
288
/// Combined fuzzy search (title + author + contents).
289
+
pub fn fuzzy(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> {
290
let key = SmolStr::new(normalize(query));
291
if key.is_empty() || limit == 0 {
292
return Vec::new();
···
363
364
/// Returns a list of all books in the library.
365
#[inline]
366
+
pub fn all_books<'a>(&'a self) -> impl Iterator<Item = &'a Book> + 'a {
367
self.books.iter()
368
}
369
370
+
/// Returns the source for a book hash, if present.
371
#[inline]
372
+
pub fn source_for_hash(&self, hash: &BookHash) -> Option<&BookSource> {
373
+
self.source_by_hash.get(hash)
374
}
375
}
376
···
380
s.to_lowercase()
381
}
382
383
+
fn book_plain_text_empty(book: &Book) -> bool {
384
+
book.content.pages.is_empty()
385
|| book
386
+
.content
387
.pages
388
.iter()
389
+
.all(|page| page.normalize().trim().is_empty())
390
}
391
392
const CACHE_BY_AUTHOR_CAP: usize = 1024;
393
const CACHE_FUZZY_CAP: usize = 256;
394
395
/// Helper to build LRU caches with non-zero capacity.
396
fn new_lru<K: std::hash::Hash + Eq, V>(cap: usize) -> LruCache<K, V> {
397
LruCache::new(NonZeroUsize::new(cap).expect("cache cap must be > 0"))
398
}
399
400
const MAX_CONTENT_INDEX_CHARS: usize = 16_384;
···
585
fn fuzzy_rank_contents(
586
query: &str,
587
norm_field: &[String],
588
+
tri_index: &AHashMap<u32, Vec<BookId>>,
589
limit: usize,
590
score_threshold: f64,
591
) -> Vec<(BookId, f64)> {
+90
-31
src/main.rs
+90
-31
src/main.rs
···
1
-
use std::{fs, io::Cursor};
2
3
use anyhow::Context;
4
use clap::Parser as _;
5
6
use crate::{
7
-
cli::Args,
8
-
library::{Library, Realm},
9
web::start_webserver,
10
};
11
12
pub mod cli;
13
pub mod library;
···
18
#[tokio::main]
19
async fn main() -> anyhow::Result<()> {
20
tracing_subscriber::fmt::init();
21
-
let cli_args = Args::parse();
22
-
let library = build_library(&cli_args).context("Building library")?;
23
24
-
if cli_args.start_webserver {
25
-
start_webserver(&cli_args, library)
26
-
.await
27
-
.context("Running webserver")?;
28
}
29
30
Ok(())
31
}
32
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")?;
46
47
-
let library = Library::new(
48
-
realm,
49
args.content_threshold,
50
-
args.author_threshold,
51
args.title_threshold,
52
-
args.warn_duplicates,
53
-
args.warn_empty,
54
-
args.filter_empty_books,
55
-
)
56
-
.context("Constructing library")?;
57
-
58
Ok(library)
59
}
···
1
+
use std::time::{Instant, SystemTime};
2
3
use anyhow::Context;
4
use clap::Parser as _;
5
+
use nara_core::BookContainer;
6
+
use nara_slurper_1_12_infinity::Realm;
7
8
use crate::{
9
+
cli::{Cli, Command, ServeArgs},
10
+
library::Library,
11
web::start_webserver,
12
};
13
+
use nara_slurper_1_12_world::slurp_world;
14
15
pub mod cli;
16
pub mod library;
···
21
#[tokio::main]
22
async fn main() -> anyhow::Result<()> {
23
tracing_subscriber::fmt::init();
24
+
let cli = Cli::parse();
25
+
26
+
match cli.command {
27
+
Command::Serve(args) => {
28
+
let library = build_library(&args).context("Building library")?;
29
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
+
},
85
}
86
87
Ok(())
88
}
89
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
+
);
99
100
+
let start = SystemTime::now();
101
+
let mut library = Library::new(
102
args.content_threshold,
103
args.title_threshold,
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
+
);
117
Ok(library)
118
}
+7
-79
src/web/api.rs
+7
-79
src/web/api.rs
···
7
response::{IntoResponse, Response},
8
routing::get,
9
};
10
use serde::{Deserialize, Serialize};
11
12
-
use crate::library::{self, Library, item::WrittenBookTag, text::Component};
13
14
#[derive(Clone)]
15
pub struct AppState {
···
23
.route("/health", get(health))
24
.route("/books", get(list_books))
25
.route("/books/by-author", get(books_by_author))
26
-
.route("/books/by-category", get(books_by_category))
27
.route("/books/by-hash/{hash}", get(book_by_hash))
28
-
.route("/categories", get(list_categories))
29
.route("/search", get(search_all))
30
.route("/search/title", get(search_title))
31
.route("/search/author", get(search_author))
···
36
#[derive(serde::Serialize)]
37
struct RootResponse {
38
message: &'static str,
39
-
endpoints: [&'static str; 10],
40
}
41
42
async fn root() -> axum::Json<RootResponse> {
···
46
"/api/health",
47
"/api/books?limit=25&offset=0",
48
"/api/books/by-author?author=Name&limit=25&offset=0",
49
-
"/api/books/by-category?category=Category&limit=25&offset=0",
50
"/api/books/by-hash/:hash",
51
-
"/api/categories",
52
"/api/search?query=term&limit=25&offset=0",
53
"/api/search/title?query=term&limit=25&offset=0",
54
"/api/search/author?query=term&limit=25&offset=0",
···
139
Ok(Json(items))
140
}
141
142
-
#[derive(Deserialize)]
143
-
struct CategoryQuery {
144
-
category: String,
145
-
limit: Option<usize>,
146
-
offset: Option<usize>,
147
-
}
148
-
149
-
async fn books_by_category(
150
-
State(state): State<AppState>,
151
-
Query(query): Query<CategoryQuery>,
152
-
) -> Result<Json<Vec<BookDetail>>, ApiError> {
153
-
let library = state.library.lock().expect("library mutex poisoned");
154
-
let category = query.category.trim();
155
-
if category.is_empty() {
156
-
return Err(ApiError::bad_request("category is required"));
157
-
}
158
-
159
-
let limit = clamp_limit(query.limit);
160
-
let offset = query.offset.unwrap_or(0);
161
-
162
-
let items = library
163
-
.books_in_category(category)
164
-
.skip(offset)
165
-
.take(limit)
166
-
.map(|book| book_to_detail(book, &library))
167
-
.collect();
168
-
169
-
Ok(Json(items))
170
-
}
171
-
172
async fn book_by_hash(
173
State(state): State<AppState>,
174
Path(hash): Path<String>,
···
182
Ok(Json(book_to_detail(book, &library)))
183
}
184
185
-
#[derive(Serialize)]
186
-
struct CategorySummary {
187
-
name: String,
188
-
count: usize,
189
-
}
190
-
191
-
async fn list_categories(
192
-
State(state): State<AppState>,
193
-
) -> Json<Vec<CategorySummary>> {
194
-
let library = state.library.lock().expect("library mutex poisoned");
195
-
let mut categories: Vec<CategorySummary> = library
196
-
.categories()
197
-
.into_iter()
198
-
.map(|(name, count)| CategorySummary {
199
-
name: name.to_string(),
200
-
count,
201
-
})
202
-
.collect();
203
-
categories.sort_by(|a, b| a.name.cmp(&b.name));
204
-
Json(categories)
205
-
}
206
-
207
#[derive(Deserialize)]
208
struct SearchQuery {
209
query: String,
···
252
fn run_search(
253
library: &Library,
254
query: SearchQuery,
255
-
search_fn: for<'a> fn(
256
-
&'a Library,
257
-
&str,
258
-
usize,
259
-
) -> Vec<(&'a WrittenBookTag, f64)>,
260
) -> Result<Json<Vec<SearchResult>>, ApiError> {
261
let query_str = query.query.trim();
262
if query_str.is_empty() {
···
281
282
#[derive(Serialize)]
283
struct BookDetail {
284
-
title: String,
285
-
author: String,
286
-
pages: Vec<Component>,
287
hash: String,
288
-
location: Option<String>,
289
-
category: Option<String>,
290
}
291
292
-
fn book_to_detail(book: &WrittenBookTag, library: &Library) -> BookDetail {
293
let hash = book.hash();
294
let hash_hex = hex::encode(hash);
295
-
let location = library.location_for_hash(&hash).map(|s| s.to_string());
296
-
let category = location
297
-
.as_deref()
298
-
.and_then(library::category_from_location)
299
-
.map(|s| s.to_string());
300
301
BookDetail {
302
-
title: book.title.clone(),
303
-
author: book.author.clone(),
304
-
pages: book.pages.clone(),
305
hash: hash_hex,
306
-
location,
307
-
category,
308
}
309
}
310
···
7
response::{IntoResponse, Response},
8
routing::get,
9
};
10
+
use nara_core::book::Book;
11
use serde::{Deserialize, Serialize};
12
13
+
use crate::library::Library;
14
15
#[derive(Clone)]
16
pub struct AppState {
···
24
.route("/health", get(health))
25
.route("/books", get(list_books))
26
.route("/books/by-author", get(books_by_author))
27
.route("/books/by-hash/{hash}", get(book_by_hash))
28
.route("/search", get(search_all))
29
.route("/search/title", get(search_title))
30
.route("/search/author", get(search_author))
···
35
#[derive(serde::Serialize)]
36
struct RootResponse {
37
message: &'static str,
38
+
endpoints: [&'static str; 8],
39
}
40
41
async fn root() -> axum::Json<RootResponse> {
···
45
"/api/health",
46
"/api/books?limit=25&offset=0",
47
"/api/books/by-author?author=Name&limit=25&offset=0",
48
"/api/books/by-hash/:hash",
49
"/api/search?query=term&limit=25&offset=0",
50
"/api/search/title?query=term&limit=25&offset=0",
51
"/api/search/author?query=term&limit=25&offset=0",
···
136
Ok(Json(items))
137
}
138
139
async fn book_by_hash(
140
State(state): State<AppState>,
141
Path(hash): Path<String>,
···
149
Ok(Json(book_to_detail(book, &library)))
150
}
151
152
#[derive(Deserialize)]
153
struct SearchQuery {
154
query: String,
···
197
fn run_search(
198
library: &Library,
199
query: SearchQuery,
200
+
search_fn: for<'a> fn(&'a Library, &str, usize) -> Vec<(&'a Book, f64)>,
201
) -> Result<Json<Vec<SearchResult>>, ApiError> {
202
let query_str = query.query.trim();
203
if query_str.is_empty() {
···
222
223
#[derive(Serialize)]
224
struct BookDetail {
225
+
book: Book,
226
hash: String,
227
}
228
229
+
fn book_to_detail(book: &Book, _library: &Library) -> BookDetail {
230
let hash = book.hash();
231
let hash_hex = hex::encode(hash);
232
233
BookDetail {
234
+
book: book.clone(),
235
hash: hash_hex,
236
}
237
}
238
+63
-84
src/web/assets/stylesheet.css
+63
-84
src/web/assets/stylesheet.css
···
1
@font-face {
2
font-family: "Minecraft";
3
-
src: url("/assets/font/regular.woff2?v=__NARA_ASSET_VERSION__") format("woff2");
4
font-weight: 400;
5
font-style: normal;
6
font-display: swap;
···
16
17
@font-face {
18
font-family: "Minecraft";
19
-
src: url("/assets/font/italic.woff2?v=__NARA_ASSET_VERSION__") format("woff2");
20
font-weight: 400;
21
font-style: italic;
22
font-display: swap;
···
24
25
@font-face {
26
font-family: "Minecraft";
27
-
src: url("/assets/font/bold_italic.woff2?v=__NARA_ASSET_VERSION__") format("woff2");
28
font-weight: 700;
29
font-style: italic;
30
font-display: swap;
31
}
32
33
.font-minecraft {
34
-
font-family: "Minecraft", system-ui, -apple-system, "Segoe UI", sans-serif;
35
font-synthesis: none;
36
}
37
···
44
}
45
46
.text-dark_blue {
47
-
color: #0000AA;
48
}
49
50
.text-dark_green {
51
-
color: #00AA00;
52
}
53
54
.text-dark_aqua {
55
-
color: #00AAAA;
56
}
57
58
.text-dark_red {
59
-
color: #AA0000;
60
}
61
62
.text-dark_purple {
63
-
color: #AA00AA;
64
}
65
66
.text-gold {
67
-
color: #FFAA00;
68
}
69
70
.text-gray {
71
-
color: #AAAAAA;
72
}
73
74
.text-dark_gray {
···
76
}
77
78
.text-blue {
79
-
color: #5555FF;
80
}
81
82
.text-green {
83
-
color: #55FF55;
84
}
85
86
.text-aqua {
87
-
color: #55FFFF;
88
}
89
90
.text-red {
91
-
color: #FF5555;
92
}
93
94
.text-light_purple {
95
-
color: #FF55FF;
96
}
97
98
.text-yellow {
99
-
color: #FFFF55;
100
}
101
102
.text-white {
103
-
color: #FFFFFF;
104
}
105
106
:root {
···
118
119
--accent: #4f9a3a;
120
--accent-strong: #336f26;
121
-
--category-active-border: #275117;
122
-
--category-active-surface: #78b65a;
123
-
--category-active-ink: #0f2a09;
124
125
--border: #3b3b3b;
126
--button-border: #1f1f1f;
···
168
margin: 0;
169
font-family: inherit;
170
color: var(--ink);
171
-
background:
172
-
linear-gradient(45deg, #7f7f7f 25%, #777777 25%, #777777 50%, #7f7f7f 50%, #7f7f7f 75%, #777777 75%, #777777 100%);
173
background-size: 1rem 1rem;
174
}
175
···
206
background: var(--tile-surface);
207
}
208
209
-
:is(.book-badge, .book-icon, .category-link, .tag, .chip, .detail-link, button, .button, .link-reset, input[type="search"], select) {
210
border-radius: var(--radius-control);
211
}
212
···
338
339
.layout {
340
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
}
389
390
.results-header {
···
412
.book-tile {
413
text-align: center;
414
padding: 0.75rem 0.625rem;
415
-
transition: border 0.15s ease, background 0.15s ease;
416
}
417
418
.book-tile:hover {
···
434
align-items: center;
435
justify-content: center;
436
box-shadow: var(--bevel-control);
437
-
transition: transform 0.15s ease, box-shadow 0.15s ease;
438
}
439
440
.book-icon:hover {
···
521
522
.pager {
523
margin-top: 1.125rem;
524
display: flex;
525
gap: 0.75rem;
526
}
···
567
margin-top: 1.5rem;
568
margin-bottom: 1.5rem;
569
width: 100%;
570
-
max-width: calc((var(--book-sprite-width) * 3) + (var(--book-grid-gap) * 2));
571
margin-inline: auto;
572
}
573
···
659
mask-image: var(--mask, var(--book-mask));
660
mask-repeat: no-repeat;
661
mask-size: 100% 100%;
662
-
animation: glintMove 10s linear infinite, glintHue 10s linear infinite;
663
}
664
665
@keyframes glintMove {
···
683
}
684
685
@media (max-width: 61.25rem) {
686
-
.layout {
687
-
grid-template-columns: 1fr;
688
-
}
689
-
690
-
.sidebar .panel {
691
-
position: static;
692
-
}
693
-
694
.search {
695
grid-template-columns: 1fr;
696
}
···
706
flex-direction: column;
707
align-items: flex-start;
708
}
709
-
}
···
1
@font-face {
2
font-family: "Minecraft";
3
+
src: url("/assets/font/regular.woff2?v=__NARA_ASSET_VERSION__")
4
+
format("woff2");
5
font-weight: 400;
6
font-style: normal;
7
font-display: swap;
···
17
18
@font-face {
19
font-family: "Minecraft";
20
+
src: url("/assets/font/italic.woff2?v=__NARA_ASSET_VERSION__")
21
+
format("woff2");
22
font-weight: 400;
23
font-style: italic;
24
font-display: swap;
···
26
27
@font-face {
28
font-family: "Minecraft";
29
+
src: url("/assets/font/bold_italic.woff2?v=__NARA_ASSET_VERSION__")
30
+
format("woff2");
31
font-weight: 700;
32
font-style: italic;
33
font-display: swap;
34
}
35
36
.font-minecraft {
37
+
font-family:
38
+
"Minecraft",
39
+
system-ui,
40
+
-apple-system,
41
+
"Segoe UI",
42
+
sans-serif;
43
font-synthesis: none;
44
}
45
···
52
}
53
54
.text-dark_blue {
55
+
color: #0000aa;
56
}
57
58
.text-dark_green {
59
+
color: #00aa00;
60
}
61
62
.text-dark_aqua {
63
+
color: #00aaaa;
64
}
65
66
.text-dark_red {
67
+
color: #aa0000;
68
}
69
70
.text-dark_purple {
71
+
color: #aa00aa;
72
}
73
74
.text-gold {
75
+
color: #ffaa00;
76
}
77
78
.text-gray {
79
+
color: #aaaaaa;
80
}
81
82
.text-dark_gray {
···
84
}
85
86
.text-blue {
87
+
color: #5555ff;
88
}
89
90
.text-green {
91
+
color: #55ff55;
92
}
93
94
.text-aqua {
95
+
color: #55ffff;
96
}
97
98
.text-red {
99
+
color: #ff5555;
100
}
101
102
.text-light_purple {
103
+
color: #ff55ff;
104
}
105
106
.text-yellow {
107
+
color: #ffff55;
108
}
109
110
.text-white {
111
+
color: #ffffff;
112
}
113
114
:root {
···
126
127
--accent: #4f9a3a;
128
--accent-strong: #336f26;
129
130
--border: #3b3b3b;
131
--button-border: #1f1f1f;
···
173
margin: 0;
174
font-family: inherit;
175
color: var(--ink);
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
+
);
186
background-size: 1rem 1rem;
187
}
188
···
219
background: var(--tile-surface);
220
}
221
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
+
) {
235
border-radius: var(--radius-control);
236
}
237
···
363
364
.layout {
365
margin-top: 1.5rem;
366
}
367
368
.results-header {
···
390
.book-tile {
391
text-align: center;
392
padding: 0.75rem 0.625rem;
393
+
transition:
394
+
border 0.15s ease,
395
+
background 0.15s ease;
396
}
397
398
.book-tile:hover {
···
414
align-items: center;
415
justify-content: center;
416
box-shadow: var(--bevel-control);
417
+
transition:
418
+
transform 0.15s ease,
419
+
box-shadow 0.15s ease;
420
}
421
422
.book-icon:hover {
···
503
504
.pager {
505
margin-top: 1.125rem;
506
+
margin-bottom: 1.125rem;
507
display: flex;
508
gap: 0.75rem;
509
}
···
550
margin-top: 1.5rem;
551
margin-bottom: 1.5rem;
552
width: 100%;
553
+
max-width: calc(
554
+
(var(--book-sprite-width) * 3) + (var(--book-grid-gap) * 2)
555
+
);
556
margin-inline: auto;
557
}
558
···
644
mask-image: var(--mask, var(--book-mask));
645
mask-repeat: no-repeat;
646
mask-size: 100% 100%;
647
+
animation:
648
+
glintMove 10s linear infinite,
649
+
glintHue 10s linear infinite;
650
}
651
652
@keyframes glintMove {
···
670
}
671
672
@media (max-width: 61.25rem) {
673
.search {
674
grid-template-columns: 1fr;
675
}
···
685
flex-direction: column;
686
align-items: flex-start;
687
}
688
+
}
+3
-2
src/web/assets.rs
+3
-2
src/web/assets.rs
···
19
include_bytes!("assets/font/bold_italic.woff2");
20
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));
24
25
const WEBP_MIME_TYPE: &str = "image/webp";
26
const WOFF2_MIME_TYPE: &str = "font/woff2";
···
19
include_bytes!("assets/font/bold_italic.woff2");
20
21
const CSS_STYLES_TEMPLATE: &str = include_str!("assets/stylesheet.css");
22
+
static CSS_STYLES: LazyLock<String> = LazyLock::new(|| {
23
+
CSS_STYLES_TEMPLATE.replace("__NARA_ASSET_VERSION__", crate::VERSION)
24
+
});
25
26
const WEBP_MIME_TYPE: &str = "image/webp";
27
const WOFF2_MIME_TYPE: &str = "font/woff2";
+34
-196
src/web/pages.rs
+34
-196
src/web/pages.rs
···
11
extract::{Path, Query},
12
response::Redirect,
13
};
14
use serde::Deserialize;
15
use smol_str::SmolStr;
16
17
-
use crate::{
18
-
library::{Library, category_from_location, item::WrittenBookTag},
19
-
web::TextureKind,
20
-
};
21
22
#[derive(Debug, Template, WebTemplate)]
23
#[template(path = "index.html", whitespace = "minimize")]
···
38
pub next_offset: usize,
39
pub pager_query: String,
40
pub books: Vec<BookCard>,
41
-
pub categories: Vec<CategoryView>,
42
pub authors: Vec<AuthorView>,
43
pub git_hash: String,
44
}
···
58
pub struct IndexQuery {
59
q: Option<String>,
60
scope: Option<String>,
61
-
category: Option<String>,
62
author: Option<String>,
63
limit: Option<usize>,
64
offset: Option<usize>,
···
73
pub struct QueryView {
74
pub q: String,
75
pub scope: String,
76
-
pub category: String,
77
pub author: String,
78
-
pub has_category: bool,
79
pub has_author: bool,
80
pub active: bool,
81
}
82
83
#[derive(Debug)]
84
-
pub struct CategoryView {
85
-
pub name: String,
86
-
pub count: usize,
87
-
pub href: String,
88
-
pub active: bool,
89
-
}
90
-
91
-
#[derive(Debug)]
92
pub struct AuthorView {
93
pub name: String,
94
pub count: usize,
···
108
pub title: String,
109
pub author: String,
110
pub author_href: String,
111
-
pub location: String,
112
-
pub has_location: bool,
113
-
pub category: String,
114
-
pub category_href: String,
115
-
pub has_category: bool,
116
pub pages: Vec<PageView>,
117
pub page_count: usize,
118
}
···
134
135
impl HtmlSafe for HtmlText {}
136
137
-
const DEFAULT_LIMIT: usize = 25;
138
-
const MAX_LIMIT: usize = 50;
139
const SEARCH_MAX_RESULTS: usize = 200;
140
141
#[derive(Debug)]
142
struct FilterParams {
143
query: String,
144
scope: &'static str,
145
-
category: Option<String>,
146
author: Option<String>,
147
limit: usize,
148
offset: usize,
149
author_norm: Option<String>,
150
-
category_norm: Option<String>,
151
}
152
153
pub async fn index(
···
166
String,
167
usize,
168
String,
169
-
) = if !params.query.is_empty()
170
-
|| params.author.is_some()
171
-
|| params.category.is_some()
172
-
{
173
let filtered = collect_candidates(&library, ¶ms);
174
let total = filtered.len();
175
let offset_used = params.offset.min(total);
···
185
format!("Top {0} matches", total)
186
};
187
let view_label = if params.query.is_empty() {
188
-
filtered_view_label(
189
-
params.author.as_deref(),
190
-
params.category.as_deref(),
191
-
)
192
} else {
193
format!("Search results for \"{0}\"", params.query)
194
};
···
211
)
212
};
213
214
-
let categories = build_categories(&library, params.category.as_deref());
215
let authors = build_authors(&library);
216
let pager_query = build_pager_query(
217
¶ms.query,
218
params.scope,
219
-
params.category.as_deref(),
220
params.author.as_deref(),
221
);
222
-
let has_category = params.category.is_some();
223
let has_author = params.author.is_some();
224
225
let active = !params.query.is_empty()
226
-
|| params.category.is_some()
227
|| params.author.is_some()
228
|| params.offset > 0;
229
···
240
let back_href = build_index_href(
241
¶ms.query,
242
params.scope,
243
-
params.category.as_deref(),
244
params.author.as_deref(),
245
params.limit,
246
offset_used,
···
257
query: QueryView {
258
q: params.query.clone(),
259
scope: params.scope.to_string(),
260
-
category: params.category.clone().unwrap_or_default(),
261
author: params.author.clone().unwrap_or_default(),
262
-
has_category,
263
has_author,
264
active,
265
},
···
276
next_offset,
277
pager_query,
278
books,
279
-
categories,
280
authors,
281
git_hash,
282
}
···
321
let back_href = build_index_href(
322
¶ms.query,
323
params.scope,
324
-
params.category.as_deref(),
325
params.author.as_deref(),
326
params.limit,
327
params.offset,
···
342
fn from_query(query: IndexQuery) -> Self {
343
let query_text = query.q.unwrap_or_default();
344
let query_text = query_text.trim().to_string();
345
-
let category = cleaned_param(query.category);
346
let author = cleaned_param(query.author);
347
Self {
348
query: query_text,
349
scope: parse_scope(query.scope.as_deref()),
350
limit: clamp_limit(query.limit),
351
offset: query.offset.unwrap_or(0),
352
-
category_norm: category.as_deref().map(normalize_query),
353
author_norm: author.as_deref().map(normalize_query),
354
-
category,
355
author,
356
}
357
}
···
360
fn collect_candidates<'a>(
361
library: &'a Library,
362
params: &FilterParams,
363
-
) -> Vec<&'a WrittenBookTag> {
364
if !params.query.is_empty() {
365
-
// Search results are narrowed by optional author/category filters.
366
return search_books(
367
library,
368
params.scope,
···
370
SEARCH_MAX_RESULTS,
371
)
372
.into_iter()
373
-
.filter(|book| {
374
-
matches_author(book, params.author_norm.as_deref())
375
-
&& matches_category(
376
-
book,
377
-
library,
378
-
params.category_norm.as_deref(),
379
-
)
380
-
})
381
.collect();
382
}
383
384
-
if let (Some(_author_name), Some(category_name)) =
385
-
(params.author.as_deref(), params.category.as_deref())
386
-
{
387
-
return library
388
-
.books_in_category(category_name)
389
-
.filter(|book| matches_author(book, params.author_norm.as_deref()))
390
-
.collect();
391
-
}
392
if let Some(author_name) = params.author.as_deref() {
393
return library.books_by_author(author_name).collect();
394
}
395
-
if let Some(category_name) = params.category.as_deref() {
396
-
return library.books_in_category(category_name).collect();
397
-
}
398
library.all_books().collect()
399
}
400
401
-
fn filtered_view_label(author: Option<&str>, category: Option<&str>) -> String {
402
-
match (author, category) {
403
-
(Some(author), Some(category)) => {
404
-
format!("Author: {0} · Category: {1}", author, category)
405
-
}
406
-
(Some(author), None) => format!("Author: {0}", author),
407
-
(None, Some(category)) => format!("Category: {0}", category),
408
-
(None, None) => String::from("Filtered books"),
409
}
410
-
}
411
-
412
-
fn build_categories(
413
-
library: &Library,
414
-
active: Option<&str>,
415
-
) -> Vec<CategoryView> {
416
-
let mut categories: Vec<CategoryView> = library
417
-
.categories()
418
-
.into_iter()
419
-
.map(|(name, count)| {
420
-
let name_str = name.to_string();
421
-
CategoryView {
422
-
active: active
423
-
.map(|v| v.eq_ignore_ascii_case(&name_str))
424
-
.unwrap_or(false),
425
-
href: format!(
426
-
"/?category={0}",
427
-
encode_query_component(&name_str)
428
-
),
429
-
name: name_str,
430
-
count,
431
-
}
432
-
})
433
-
.collect();
434
-
categories.sort_by(|a, b| {
435
-
b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name))
436
-
});
437
-
categories
438
}
439
440
fn build_authors(library: &Library) -> Vec<AuthorView> {
441
let mut counts: ahash::AHashMap<SmolStr, (SmolStr, usize)> =
442
ahash::AHashMap::new();
443
for book in library.all_books() {
444
-
let raw = book.author.trim();
445
if raw.is_empty() {
446
continue;
447
}
···
477
(group, trimmed.to_lowercase())
478
}
479
480
-
fn build_pager_query(
481
-
q: &str,
482
-
scope: &str,
483
-
category: Option<&str>,
484
-
author: Option<&str>,
485
-
) -> String {
486
let mut parts = Vec::new();
487
if !q.is_empty() {
488
parts.push(format!("q={0}", encode_query_component(q)));
489
if scope != "all" {
490
parts.push(format!("scope={0}", encode_query_component(scope)));
491
}
492
-
}
493
-
if let Some(category) = category {
494
-
parts.push(format!("category={0}", encode_query_component(category)));
495
}
496
if let Some(author) = author {
497
parts.push(format!("author={0}", encode_query_component(author)));
···
506
fn build_index_href(
507
q: &str,
508
scope: &str,
509
-
category: Option<&str>,
510
author: Option<&str>,
511
limit: usize,
512
offset: usize,
···
518
parts.push(format!("scope={0}", encode_query_component(scope)));
519
}
520
}
521
-
if let Some(category) = category {
522
-
parts.push(format!("category={0}", encode_query_component(category)));
523
-
}
524
if let Some(author) = author {
525
parts.push(format!("author={0}", encode_query_component(author)));
526
}
···
551
}
552
}
553
554
-
struct BookMeta {
555
-
author_href: String,
556
-
location: String,
557
-
has_location: bool,
558
-
category: String,
559
-
has_category: bool,
560
-
category_href: String,
561
-
}
562
-
563
-
fn book_meta(book: &WrittenBookTag, library: &Library) -> BookMeta {
564
-
let hash = book.hash();
565
-
let location = library
566
-
.location_for_hash(&hash)
567
-
.map(|s| s.to_string())
568
-
.unwrap_or_default();
569
-
let has_location = !location.is_empty();
570
-
let category = if has_location {
571
-
category_from_location(&location)
572
-
.map(|s| s.to_string())
573
-
.unwrap_or_default()
574
-
} else {
575
-
String::new()
576
-
};
577
-
let has_category = !category.is_empty();
578
-
let category_href = if has_category {
579
-
format!("/?category={0}", encode_query_component(&category))
580
-
} else {
581
-
String::new()
582
-
};
583
-
let author_href =
584
-
format!("/?author={0}", encode_query_component(&book.author));
585
-
586
-
BookMeta {
587
-
author_href,
588
-
location,
589
-
has_location,
590
-
category,
591
-
has_category,
592
-
category_href,
593
-
}
594
-
}
595
-
596
-
fn book_card(book: &WrittenBookTag) -> BookCard {
597
let hash_hex = hex::encode(book.hash());
598
let author_href =
599
-
format!("/?author={0}", encode_query_component(&book.author));
600
601
BookCard {
602
-
title: book.title.clone(),
603
-
author: book.author.clone(),
604
author_href,
605
hash: hash_hex.clone(),
606
detail_href: format!("/book/{0}", hash_hex),
607
}
608
}
609
610
-
fn book_detail(book: &WrittenBookTag, library: &Library) -> BookDetail {
611
-
let meta = book_meta(book, library);
612
let pages = book
613
.pages
614
.iter()
615
.enumerate()
616
.map(|(idx, page)| PageView {
617
index: idx + 1,
618
-
html: HtmlText(page.to_html()),
619
})
620
.collect();
621
622
BookDetail {
623
-
title: book.title.clone(),
624
-
author: book.author.clone(),
625
-
author_href: meta.author_href,
626
-
location: meta.location,
627
-
has_location: meta.has_location,
628
-
category: meta.category,
629
-
category_href: meta.category_href,
630
-
has_category: meta.has_category,
631
pages,
632
-
page_count: book.pages.len(),
633
}
634
}
635
···
661
scope: &str,
662
query: &str,
663
fetch: usize,
664
-
) -> Vec<&'a WrittenBookTag> {
665
match scope {
666
"title" => search_title_relaxed(library, query, fetch),
667
"author" => library
···
686
library: &'a Library,
687
query: &str,
688
fetch: usize,
689
-
) -> Vec<&'a WrittenBookTag> {
690
let needle = normalize_query(query);
691
if needle.is_empty() || fetch == 0 {
692
return Vec::new();
···
696
let mut seen: AHashSet<[u8; 20]> = AHashSet::new();
697
698
for book in library.all_books() {
699
-
let title_norm = normalize_query(&book.title);
700
if title_norm.contains(&needle) {
701
let hash = book.hash();
702
if seen.insert(hash) {
···
721
out
722
}
723
724
-
fn matches_author(book: &WrittenBookTag, author_norm: Option<&str>) -> bool {
725
let Some(author_norm) = author_norm else {
726
return true;
727
};
728
-
book.author.trim().eq_ignore_ascii_case(author_norm)
729
-
}
730
-
731
-
fn matches_category(
732
-
book: &WrittenBookTag,
733
-
library: &Library,
734
-
category_norm: Option<&str>,
735
-
) -> bool {
736
-
let Some(category_norm) = category_norm else {
737
-
return true;
738
-
};
739
-
let Some(location) = library.location_for_hash(&book.hash()) else {
740
-
return false;
741
-
};
742
-
let Some(category) = category_from_location(location) else {
743
-
return false;
744
-
};
745
-
category.trim().eq_ignore_ascii_case(category_norm)
746
}
747
748
fn parse_hash(hex_str: &str) -> Option<[u8; 20]> {
···
11
extract::{Path, Query},
12
response::Redirect,
13
};
14
+
use nara_core::book::{Book, BookSource};
15
use serde::Deserialize;
16
use smol_str::SmolStr;
17
18
+
use crate::{library::Library, web::TextureKind};
19
20
#[derive(Debug, Template, WebTemplate)]
21
#[template(path = "index.html", whitespace = "minimize")]
···
36
pub next_offset: usize,
37
pub pager_query: String,
38
pub books: Vec<BookCard>,
39
pub authors: Vec<AuthorView>,
40
pub git_hash: String,
41
}
···
55
pub struct IndexQuery {
56
q: Option<String>,
57
scope: Option<String>,
58
author: Option<String>,
59
limit: Option<usize>,
60
offset: Option<usize>,
···
69
pub struct QueryView {
70
pub q: String,
71
pub scope: String,
72
pub author: String,
73
pub has_author: bool,
74
pub active: bool,
75
}
76
77
#[derive(Debug)]
78
pub struct AuthorView {
79
pub name: String,
80
pub count: usize,
···
94
pub title: String,
95
pub author: String,
96
pub author_href: String,
97
+
pub source: BookSource,
98
pub pages: Vec<PageView>,
99
pub page_count: usize,
100
}
···
116
117
impl HtmlSafe for HtmlText {}
118
119
+
const DEFAULT_LIMIT: usize = 100;
120
+
const MAX_LIMIT: usize = 200;
121
const SEARCH_MAX_RESULTS: usize = 200;
122
123
#[derive(Debug)]
124
struct FilterParams {
125
query: String,
126
scope: &'static str,
127
author: Option<String>,
128
limit: usize,
129
offset: usize,
130
author_norm: Option<String>,
131
}
132
133
pub async fn index(
···
146
String,
147
usize,
148
String,
149
+
) = if !params.query.is_empty() || params.author.is_some() {
150
let filtered = collect_candidates(&library, ¶ms);
151
let total = filtered.len();
152
let offset_used = params.offset.min(total);
···
162
format!("Top {0} matches", total)
163
};
164
let view_label = if params.query.is_empty() {
165
+
filtered_view_label(params.author.as_deref())
166
} else {
167
format!("Search results for \"{0}\"", params.query)
168
};
···
185
)
186
};
187
188
let authors = build_authors(&library);
189
let pager_query = build_pager_query(
190
¶ms.query,
191
params.scope,
192
params.author.as_deref(),
193
);
194
let has_author = params.author.is_some();
195
196
let active = !params.query.is_empty()
197
|| params.author.is_some()
198
|| params.offset > 0;
199
···
210
let back_href = build_index_href(
211
¶ms.query,
212
params.scope,
213
params.author.as_deref(),
214
params.limit,
215
offset_used,
···
226
query: QueryView {
227
q: params.query.clone(),
228
scope: params.scope.to_string(),
229
author: params.author.clone().unwrap_or_default(),
230
has_author,
231
active,
232
},
···
243
next_offset,
244
pager_query,
245
books,
246
authors,
247
git_hash,
248
}
···
287
let back_href = build_index_href(
288
¶ms.query,
289
params.scope,
290
params.author.as_deref(),
291
params.limit,
292
params.offset,
···
307
fn from_query(query: IndexQuery) -> Self {
308
let query_text = query.q.unwrap_or_default();
309
let query_text = query_text.trim().to_string();
310
let author = cleaned_param(query.author);
311
Self {
312
query: query_text,
313
scope: parse_scope(query.scope.as_deref()),
314
limit: clamp_limit(query.limit),
315
offset: query.offset.unwrap_or(0),
316
author_norm: author.as_deref().map(normalize_query),
317
author,
318
}
319
}
···
322
fn collect_candidates<'a>(
323
library: &'a Library,
324
params: &FilterParams,
325
+
) -> Vec<&'a Book> {
326
if !params.query.is_empty() {
327
return search_books(
328
library,
329
params.scope,
···
331
SEARCH_MAX_RESULTS,
332
)
333
.into_iter()
334
+
.filter(|book| matches_author(book, params.author_norm.as_deref()))
335
.collect();
336
}
337
338
if let Some(author_name) = params.author.as_deref() {
339
return library.books_by_author(author_name).collect();
340
}
341
library.all_books().collect()
342
}
343
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"),
348
}
349
}
350
351
fn build_authors(library: &Library) -> Vec<AuthorView> {
352
let mut counts: ahash::AHashMap<SmolStr, (SmolStr, usize)> =
353
ahash::AHashMap::new();
354
for book in library.all_books() {
355
+
let raw = book.content.author.trim();
356
if raw.is_empty() {
357
continue;
358
}
···
388
(group, trimmed.to_lowercase())
389
}
390
391
+
fn build_pager_query(q: &str, scope: &str, author: Option<&str>) -> String {
392
let mut parts = Vec::new();
393
if !q.is_empty() {
394
parts.push(format!("q={0}", encode_query_component(q)));
395
if scope != "all" {
396
parts.push(format!("scope={0}", encode_query_component(scope)));
397
}
398
}
399
if let Some(author) = author {
400
parts.push(format!("author={0}", encode_query_component(author)));
···
409
fn build_index_href(
410
q: &str,
411
scope: &str,
412
author: Option<&str>,
413
limit: usize,
414
offset: usize,
···
420
parts.push(format!("scope={0}", encode_query_component(scope)));
421
}
422
}
423
if let Some(author) = author {
424
parts.push(format!("author={0}", encode_query_component(author)));
425
}
···
450
}
451
}
452
453
+
fn book_card(book: &Book) -> BookCard {
454
let hash_hex = hex::encode(book.hash());
455
let author_href =
456
+
format!("/?author={0}", encode_query_component(&book.content.author));
457
458
BookCard {
459
+
title: book.content.title.clone(),
460
+
author: book.content.author.clone(),
461
author_href,
462
hash: hash_hex.clone(),
463
detail_href: format!("/book/{0}", hash_hex),
464
}
465
}
466
467
+
fn book_detail(book: &Book, _library: &Library) -> BookDetail {
468
+
let author_href =
469
+
format!("/?author={0}", encode_query_component(&book.content.author));
470
let pages = book
471
+
.content
472
.pages
473
.iter()
474
.enumerate()
475
.map(|(idx, page)| PageView {
476
index: idx + 1,
477
+
html: HtmlText(page.to_plain_text()), // TODO(kokiriglade): pass to_html or similar here
478
})
479
.collect();
480
481
BookDetail {
482
+
title: book.content.title.clone(),
483
+
author: book.content.author.clone(),
484
+
author_href,
485
+
source: book.metadata.source.clone(),
486
pages,
487
+
page_count: book.content.pages.len(),
488
}
489
}
490
···
516
scope: &str,
517
query: &str,
518
fetch: usize,
519
+
) -> Vec<&'a Book> {
520
match scope {
521
"title" => search_title_relaxed(library, query, fetch),
522
"author" => library
···
541
library: &'a Library,
542
query: &str,
543
fetch: usize,
544
+
) -> Vec<&'a Book> {
545
let needle = normalize_query(query);
546
if needle.is_empty() || fetch == 0 {
547
return Vec::new();
···
551
let mut seen: AHashSet<[u8; 20]> = AHashSet::new();
552
553
for book in library.all_books() {
554
+
let title_norm = normalize_query(&book.content.title);
555
if title_norm.contains(&needle) {
556
let hash = book.hash();
557
if seen.insert(hash) {
···
576
out
577
}
578
579
+
fn matches_author(book: &Book, author_norm: Option<&str>) -> bool {
580
let Some(author_norm) = author_norm else {
581
return true;
582
};
583
+
book.content.author.trim().eq_ignore_ascii_case(author_norm)
584
}
585
586
fn parse_hash(hex_str: &str) -> Option<[u8; 20]> {
+2
-2
src/web.rs
+2
-2
src/web.rs
···
8
use tokio::net::TcpListener;
9
use tower_http::compression::CompressionLayer;
10
11
-
use crate::{cli::Args, library::Library};
12
13
pub mod api;
14
pub mod assets;
···
32
}
33
34
pub async fn start_webserver(
35
-
args: &Args,
36
library: Library,
37
) -> anyhow::Result<()> {
38
let library = Arc::new(Mutex::new(library));
···
8
use tokio::net::TcpListener;
9
use tower_http::compression::CompressionLayer;
10
11
+
use crate::{cli::ServeArgs, library::Library};
12
13
pub mod api;
14
pub mod assets;
···
32
}
33
34
pub async fn start_webserver(
35
+
args: &ServeArgs,
36
library: Library,
37
) -> anyhow::Result<()> {
38
let library = Arc::new(Mutex::new(library));
+50
-43
templates/book.html
+50
-43
templates/book.html
···
1
-
{% extends "base.html" %}
2
-
3
-
{% block title %}{{ book.title }} - nara{% endblock %}
4
{% block head %}
5
-
<link rel="icon" type="image/webp" href="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" />
6
<meta property="og:site_name" content="nara" />
7
<meta property="og:type" content="article" />
8
<meta property="og:title" content="{{ book.title }} - nara" />
9
-
<meta property="og:description" content="By {{ book.author }}. {{ book.page_count }} pages." />
10
<meta name="twitter:card" content="summary_large_image" />
11
<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 %}
19
<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>
37
</div>
38
-
</div>
39
-
<div class="detail-actions">
40
-
<a class="button ghost" href="{{ back_href }}">Back to list</a>
41
-
</div>
42
</header>
43
44
<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>
55
</section>
56
{% endblock %}
···
1
+
{% extends "base.html" %} {% block title %}{{ book.title }} - nara{% endblock %}
2
{% block head %}
3
+
<link
4
+
rel="icon"
5
+
type="image/webp"
6
+
href="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}"
7
+
/>
8
<meta property="og:site_name" content="nara" />
9
<meta property="og:type" content="article" />
10
<meta property="og:title" content="{{ book.title }} - nara" />
11
+
<meta
12
+
property="og:description"
13
+
content="By {{ book.author }}. {{ book.page_count }} pages."
14
+
/>
15
<meta name="twitter:card" content="summary_large_image" />
16
<meta name="twitter:title" content="{{ book.title }} - nara" />
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 %}
24
<header class="hero detail-hero">
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>
43
</div>
44
+
<div class="detail-actions">
45
+
<a class="button ghost" href="{{ back_href }}">Back to list</a>
46
+
</div>
47
</header>
48
49
<section class="detail-body">
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>
62
</section>
63
{% endblock %}
+1
-34
templates/index.html
+1
-34
templates/index.html
···
53
{% endfor %}
54
</select>
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
<div class="actions">
67
<button type="submit">Search</button>
68
{% if query.active %}
···
72
</div>
73
</form>
74
75
-
{% if query.has_category || query.has_author %}
76
<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
<span class="chip">Author: {{ query.author }}</span>
82
-
{% endif %}
83
</div>
84
{% endif %}
85
</header>
86
87
<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
<section class="results">
106
<div class="results-header">
107
<div>
···
158
{% endif %}
159
</nav>
160
{% endif %}
161
-
</section>
162
</section>
163
{% endblock %}
···
53
{% endfor %}
54
</select>
55
</label>
56
<div class="actions">
57
<button type="submit">Search</button>
58
{% if query.active %}
···
62
</div>
63
</form>
64
65
+
{% if query.has_author %}
66
<div class="active-filters">
67
<span class="chip">Author: {{ query.author }}</span>
68
</div>
69
{% endif %}
70
</header>
71
72
<section class="layout">
73
<section class="results">
74
<div class="results-header">
75
<div>
···
126
{% endif %}
127
</nav>
128
{% endif %}
129
</section>
130
{% endblock %}
History
1 round
0 comments
kokirigla.de
submitted
#0
17 commits
expand
collapse
feat: split cli
refactor: prepare for sub-projects
chore: update .gitignore
feat: text colors
not sold on this implementation. will probably change it drastically.
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