online Minecraft written book viewer

feat(*): a lot more work on abstraction

kokirigla.de 070328c3 36623306

verified
+985 -991
+21 -11
Cargo.lock
··· 1023 1023 "hex", 1024 1024 "html-escape", 1025 1025 "lru", 1026 + "nara_core", 1026 1027 "serde", 1027 1028 "serde_json", 1028 1029 "serde_with", ··· 1037 1038 ] 1038 1039 1039 1040 [[package]] 1040 - name = "nara_1_12" 1041 + name = "nara_core" 1041 1042 version = "0.1.0" 1042 1043 dependencies = [ 1043 - "nara_text", 1044 + "insta", 1044 1045 "serde", 1046 + "serde_json", 1047 + "serde_with", 1048 + "sha1", 1045 1049 ] 1046 1050 1047 1051 [[package]] ··· 1055 1059 ] 1056 1060 1057 1061 [[package]] 1058 - name = "nara_slurper_1_12_world" 1062 + name = "nara_slurper_1_12_core" 1059 1063 version = "0.1.0" 1060 1064 dependencies = [ 1061 - "crab_nbt", 1062 - "insta", 1063 - "nara_1_12", 1064 - "nara_io", 1065 - "nara_text", 1065 + "nara_core", 1066 1066 "serde", 1067 - "serde_with", 1068 1067 ] 1069 1068 1070 1069 [[package]] 1071 - name = "nara_text" 1070 + name = "nara_slurper_1_12_infinity" 1072 1071 version = "0.1.0" 1073 1072 dependencies = [ 1073 + "nara_slurper_1_12_core", 1074 + "serde", 1075 + ] 1076 + 1077 + [[package]] 1078 + name = "nara_slurper_1_12_world" 1079 + version = "0.1.0" 1080 + dependencies = [ 1081 + "crab_nbt", 1074 1082 "insta", 1083 + "nara_core", 1084 + "nara_io", 1085 + "nara_slurper_1_12_core", 1075 1086 "serde", 1076 - "serde_json", 1077 1087 "serde_with", 1078 1088 ] 1079 1089
+5 -3
Cargo.toml
··· 1 1 [workspace] 2 2 resolver = "3" 3 - members = ["nara_io", "nara_1_12", "nara_slurper_1_12_world", "nara_text"] 3 + members = ["nara_io", "nara_slurper_1_12_core", "nara_slurper_1_12_infinity", "nara_slurper_1_12_world", "nara_core"] 4 4 5 5 [workspace.package] 6 6 version = "0.1.0" ··· 19 19 html-escape = "=0.2.13" 20 20 insta = "=1.46.3" 21 21 lru = "=0.16.3" 22 - nara_1_12 = { path = "nara_1_12" } 22 + nara_slurper_1_12_core = { path = "nara_slurper_1_12_core" } 23 23 nara_io = { path = "nara_io" } 24 + nara_slurper_1_12_infinity = { path = "nara_slurper_1_12_infinity" } 24 25 nara_slurper_1_12_world = { path = "nara_slurper_1_12_world" } 25 - nara_text = { path = "nara_text" } 26 + nara_core = { path = "nara_core" } 26 27 serde = { version = "=1.0.228", features = ["derive"] } 27 28 serde_json = "=1.0.149" 28 29 serde_with = "=3.17.0" ··· 51 52 hex.workspace = true 52 53 html-escape.workspace = true 53 54 lru.workspace = true 55 + nara_core.workspace = true 54 56 serde.workspace = true 55 57 serde_json.workspace = true 56 58 serde_with.workspace = true
+2 -2
justfile
··· 13 13 @cargo run --release -- {{ args }} 14 14 15 15 lint: 16 - @cargo clippy 16 + @cargo clippy --workspace 17 17 18 18 alias b := build 19 19 ··· 27 27 @cargo test --workspace 28 28 29 29 ok: lint test 30 - @cargo check --workspace 30 + @cargo fmt --check
+2 -2
nara_1_12/Cargo.toml nara_slurper_1_12_core/Cargo.toml
··· 1 1 [package] 2 - name = "nara_1_12" 2 + name = "nara_slurper_1_12_core" 3 3 version.workspace = true 4 4 edition.workspace = true 5 5 6 6 [dependencies] 7 - nara_text.workspace = true 7 + nara_core.workspace = true 8 8 serde.workspace = true
+1 -1
nara_1_12/README.md nara_slurper_1_12_core/README.md
··· 1 - # nara_1_12 1 + # nara_slurper_1_12_core 2 2 3 3 Shared code for 1.12.2 version handling.
+11 -11
nara_1_12/src/item.rs nara_slurper_1_12_core/src/item.rs
··· 1 - use nara_text::Component; 1 + use nara_core::component::Component; 2 2 use serde::Deserialize; 3 3 4 - #[derive(Debug, Deserialize)] 4 + #[derive(Debug, Clone, Deserialize)] 5 5 #[serde(untagged)] 6 6 pub enum ItemStack { 7 7 WrittenBook(WrittenBookStack), ··· 9 9 Base(BaseItemStack), 10 10 } 11 11 12 - #[derive(Debug, Deserialize)] 12 + #[derive(Debug, Clone, Deserialize)] 13 13 #[serde(rename_all = "PascalCase")] 14 14 pub struct BaseItemStack { 15 15 pub count: i8, ··· 18 18 pub id: String, 19 19 } 20 20 21 - #[derive(Debug, Deserialize)] 21 + #[derive(Debug, Clone, Deserialize)] 22 22 #[serde(rename_all = "PascalCase")] 23 23 pub struct DisplayTag { 24 24 pub name: String, 25 25 } 26 26 27 - #[derive(Debug, Deserialize)] 27 + #[derive(Debug, Clone, Deserialize)] 28 28 pub struct BaseTag { 29 29 #[serde(rename = "display")] 30 30 pub display: Option<DisplayTag>, 31 31 } 32 32 33 - #[derive(Debug, Deserialize)] 33 + #[derive(Debug, Clone, Deserialize)] 34 34 pub struct WrittenBookStack { 35 35 #[serde(flatten)] 36 36 pub base: BaseItemStack, 37 37 pub tag: WrittenBookTag, 38 38 } 39 39 40 - #[derive(Debug, Deserialize)] 40 + #[derive(Debug, Clone, Deserialize)] 41 41 pub struct WrittenBookTag { 42 42 #[serde(flatten)] 43 43 pub base: BaseTag, ··· 74 74 } 75 75 } 76 76 77 - #[derive(Debug, Deserialize)] 77 + #[derive(Debug, Clone, Deserialize)] 78 78 pub struct BlockEntityStack { 79 79 #[serde(flatten)] 80 80 pub base: BaseItemStack, 81 81 pub tag: BlockEntityTag, 82 82 } 83 83 84 - #[derive(Debug, Deserialize)] 84 + #[derive(Debug, Clone, Deserialize)] 85 85 pub struct BlockEntityTag { 86 86 #[serde(flatten)] 87 87 pub base: BaseTag, ··· 89 89 pub block_entity: BlockEntity, 90 90 } 91 91 92 - #[derive(Debug, Deserialize)] 92 + #[derive(Debug, Clone, Deserialize)] 93 93 #[serde(rename_all = "PascalCase")] 94 94 pub struct BlockEntity { 95 95 #[serde(default)] ··· 123 123 } 124 124 } 125 125 126 - #[derive(Debug, Deserialize)] 126 + #[derive(Debug, Clone, Deserialize)] 127 127 #[serde(rename_all = "PascalCase")] 128 128 pub struct InventoryItemStack { 129 129 #[serde(flatten)]
-1
nara_1_12/src/lib.rs
··· 1 - pub mod item;
+104
nara_core/src/book.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use sha1::Digest as _; 3 + 4 + use crate::component::Component; 5 + 6 + pub type BookHash = [u8; 20]; 7 + 8 + #[derive(Debug, Clone, Serialize, Deserialize)] 9 + pub struct Book { 10 + pub metadata: BookMetadata, 11 + pub content: BookContent, 12 + } 13 + 14 + impl Book { 15 + /// Computes the hash of the book's contents. 16 + /// 17 + /// Metadata (such as source, generation, or resolved flag) is not taken 18 + /// into account when calculating the hash. 19 + pub fn hash(&self) -> BookHash { 20 + self.content.hash() 21 + } 22 + } 23 + 24 + #[derive(Debug, Clone, Serialize, Deserialize)] 25 + pub struct BookMetadata { 26 + pub source: BookSource, 27 + } 28 + 29 + #[derive(Debug, Clone, Serialize, Deserialize)] 30 + #[serde(tag = "type", rename_all = "snake_case")] 31 + pub enum BookSource { 32 + /// From an Infinity Item Editor realm file. 33 + InfinityRealm, 34 + /// From a player's save data. 35 + PlayerData { 36 + uuid: String, 37 + inventory: PlayerInventoryKind, 38 + }, 39 + /// From a placed block entity, such as a chest. 40 + BlockEntity { 41 + dimension: String, 42 + x: i32, 43 + y: i32, 44 + z: i32, 45 + }, 46 + } 47 + 48 + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 49 + #[serde(rename_all = "snake_case")] 50 + pub enum PlayerInventoryKind { 51 + Inventory, 52 + EnderChest, 53 + } 54 + 55 + #[derive(Debug, Clone, Serialize, Deserialize)] 56 + #[serde(rename_all = "snake_case")] 57 + pub struct BookContent { 58 + pub author: String, 59 + pub pages: Vec<Component>, 60 + pub title: String, 61 + #[serde(default)] 62 + pub generation: BookGeneration, 63 + #[serde(default)] 64 + pub resolved: bool, 65 + } 66 + 67 + impl BookContent { 68 + /// Computes the hash of the book's contents. 69 + /// 70 + /// Metadata (such as generation or resolved flag) is not taken into account 71 + /// when calculating the hash. 72 + pub fn hash(&self) -> BookHash { 73 + let mut ctx = sha1::Sha1::new(); 74 + 75 + #[inline(always)] 76 + fn put_str(ctx: &mut sha1::Sha1, s: &str) { 77 + ctx.update(s.as_bytes()); 78 + } 79 + 80 + #[inline(always)] 81 + fn put_vec_page(ctx: &mut sha1::Sha1, v: &[Component]) { 82 + for c in v { 83 + let s = c.normalize(); 84 + put_str(ctx, &s); 85 + } 86 + } 87 + 88 + put_str(&mut ctx, &self.author); 89 + put_str(&mut ctx, &self.title); 90 + put_vec_page(&mut ctx, &self.pages); 91 + 92 + ctx.finalize().0 93 + } 94 + } 95 + 96 + #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] 97 + #[serde(rename_all = "snake_case")] 98 + pub enum BookGeneration { 99 + #[default] 100 + Original, 101 + CopyOfOriginal, 102 + CopyOfCopy, 103 + Tattered, 104 + }
+4
nara_core/src/lib.rs
··· 1 + pub mod book; 2 + pub mod color; 3 + pub mod component; 4 + pub mod profile;
+27
nara_slurper_1_12_core/src/lib.rs
··· 1 + use crate::item::{InventoryItemStack, ItemStack, WrittenBookStack}; 2 + 3 + pub mod item; 4 + 5 + pub fn extract_books_from_inventory( 6 + inventory: &[InventoryItemStack], 7 + ) -> Vec<WrittenBookStack> { 8 + let mut books: Vec<WrittenBookStack> = Vec::new(); 9 + 10 + inventory.iter().for_each(|item| match &item.item { 11 + ItemStack::WrittenBook(written_book_stack) => { 12 + books.push(written_book_stack.clone()) 13 + } 14 + ItemStack::BlockEntity(block_entity_stack) => { 15 + extract_books_from_inventory( 16 + &block_entity_stack.tag.block_entity.items, 17 + ) 18 + .into_iter() 19 + .for_each(|book| { 20 + books.push(book); 21 + }); 22 + } 23 + _ => {} 24 + }); 25 + 26 + books 27 + }
+8
nara_slurper_1_12_infinity/Cargo.toml
··· 1 + [package] 2 + name = "nara_slurper_1_12_infinity" 3 + version.workspace = true 4 + edition.workspace = true 5 + 6 + [dependencies] 7 + nara_slurper_1_12_core.workspace = true 8 + serde.workspace = true
+5
nara_slurper_1_12_infinity/README.md
··· 1 + # nara_slurper_1_12_infinity 2 + 3 + Pulls books from an [Infinity Item Editor][iie] vault. 4 + 5 + [iie]: https://modrinth.com/mod/infinity-item-editor/version/0.15
+8
nara_slurper_1_12_infinity/src/lib.rs
··· 1 + use nara_slurper_1_12_core::item::ItemStack; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, Deserialize)] 5 + pub struct Realm { 6 + pub realm: Vec<ItemStack>, 7 + pub realm_version: String, 8 + }
+2 -2
nara_slurper_1_12_world/Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 crab_nbt.workspace = true 8 - nara_1_12.workspace = true 8 + nara_slurper_1_12_core.workspace = true 9 9 nara_io.workspace = true 10 - nara_text.workspace = true 10 + nara_core.workspace = true 11 11 serde.workspace = true 12 12 serde_with.workspace = true 13 13
+17 -1
nara_slurper_1_12_world/src/lib.rs
··· 1 - use nara_1_12::item::InventoryItemStack; 1 + use nara_slurper_1_12_core::{ 2 + extract_books_from_inventory, 3 + item::{InventoryItemStack, WrittenBookStack}, 4 + }; 2 5 use serde::Deserialize; 3 6 4 7 // the shit we care about from playerdata.dat files :^) ··· 14 17 )] 15 18 pub ender_chest: Vec<InventoryItemStack>, 16 19 } 20 + 21 + pub fn extract_books_from_playerdata( 22 + player_data: &PlayerData, 23 + ) -> Vec<WrittenBookStack> { 24 + let mut books: Vec<WrittenBookStack> = Vec::new(); 25 + extract_books_from_inventory(&player_data.inventory) 26 + .into_iter() 27 + .for_each(|book| books.push(book)); 28 + extract_books_from_inventory(&player_data.ender_chest) 29 + .into_iter() 30 + .for_each(|book| books.push(book)); 31 + books 32 + }
+13 -3
nara_slurper_1_12_world/tests/data.rs
··· 1 - use nara_slurper_1_12_world::PlayerData; 1 + use nara_slurper_1_12_world::{PlayerData, extract_books_from_playerdata}; 2 2 3 3 const PLAYER_DATA_FIXTURE: &[u8] = include_bytes!("fixtures/playerdata.dat"); 4 4 5 + fn get_playerdata() -> PlayerData { 6 + nara_io::read_nbt(PLAYER_DATA_FIXTURE).unwrap() 7 + } 8 + 5 9 #[test] 6 10 fn playerdata() { 7 - let parsed = nara_io::read_nbt::<PlayerData>(PLAYER_DATA_FIXTURE); 8 - insta::assert_debug_snapshot!(parsed); 11 + insta::assert_debug_snapshot!(get_playerdata()); 12 + } 13 + 14 + #[test] 15 + fn playerdata_extract() { 16 + insta::assert_debug_snapshot!(extract_books_from_playerdata( 17 + &get_playerdata() 18 + )); 9 19 }
+229 -100
nara_slurper_1_12_world/tests/snapshots/data__playerdata.snap
··· 1 1 --- 2 2 source: nara_slurper_1_12_world/tests/data.rs 3 - expression: parsed 3 + expression: get_playerdata() 4 4 --- 5 - Ok( 6 - PlayerData { 7 - inventory: [ 8 - InventoryItemStack { 9 - item: WrittenBook( 10 - WrittenBookStack { 11 - base: BaseItemStack { 12 - count: 1, 13 - damage: 0, 14 - id: "minecraft:written_book", 15 - }, 16 - tag: WrittenBookTag { 17 - base: BaseTag { 18 - display: None, 19 - }, 20 - author: "kokiriglade", 21 - title: "just a book", 22 - generation: Original, 23 - resolved: false, 24 - pages: [ 25 - String( 26 - "{\"text\":\"Hello, World!\\n\\n§cThis is red text!§r\\n\\n§9This is blue :^)§r§r\"}", 27 - ), 28 - ], 29 - }, 5 + PlayerData { 6 + inventory: [ 7 + InventoryItemStack { 8 + item: WrittenBook( 9 + WrittenBookStack { 10 + base: BaseItemStack { 11 + count: 1, 12 + damage: 0, 13 + id: "minecraft:written_book", 30 14 }, 31 - ), 32 - slot: 0, 33 - }, 34 - InventoryItemStack { 35 - item: BlockEntity( 36 - BlockEntityStack { 37 - base: BaseItemStack { 38 - count: 1, 39 - damage: 0, 40 - id: "minecraft:pink_shulker_box", 15 + tag: WrittenBookTag { 16 + base: BaseTag { 17 + display: None, 41 18 }, 42 - tag: BlockEntityTag { 43 - base: BaseTag { 44 - display: None, 45 - }, 46 - block_entity: BlockEntity { 47 - items: [ 48 - InventoryItemStack { 49 - item: WrittenBook( 50 - WrittenBookStack { 51 - base: BaseItemStack { 52 - count: 1, 53 - damage: 0, 54 - id: "minecraft:written_book", 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!", 55 56 }, 56 - tag: WrittenBookTag { 57 - base: BaseTag { 58 - display: None, 59 - }, 60 - author: "kokiriglade", 61 - title: "in a shulker box", 62 - generation: Original, 63 - resolved: false, 64 - pages: [ 65 - String( 66 - "{\"text\":\"this one is in a shulker box!! wow!!!\"}", 67 - ), 68 - ], 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", 69 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, 70 101 }, 71 - ), 72 - slot: 2, 73 - }, 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 + ), 74 134 ], 75 - }, 76 - }, 77 - }, 78 - ), 79 - slot: 1, 80 - }, 81 - InventoryItemStack { 82 - item: Base( 83 - BaseItemStack { 84 - count: 1, 85 - damage: 0, 86 - id: "minecraft:stone", 135 + ), 136 + ], 87 137 }, 88 - ), 89 - slot: 2, 90 - }, 91 - InventoryItemStack { 92 - item: Base( 93 - BaseItemStack { 138 + }, 139 + ), 140 + slot: 0, 141 + }, 142 + InventoryItemStack { 143 + item: BlockEntity( 144 + BlockEntityStack { 145 + base: BaseItemStack { 94 146 count: 1, 95 147 damage: 0, 96 - id: "minecraft:reeds", 148 + id: "minecraft:pink_shulker_box", 97 149 }, 98 - ), 99 - slot: 4, 100 - }, 101 - InventoryItemStack { 102 - item: Base( 103 - BaseItemStack { 104 - count: 1, 105 - damage: 0, 106 - id: "minecraft:golden_chestplate", 150 + tag: BlockEntityTag { 151 + base: BaseTag { 152 + display: None, 153 + }, 154 + block_entity: BlockEntity { 155 + items: [ 156 + InventoryItemStack { 157 + item: WrittenBook( 158 + WrittenBookStack { 159 + base: BaseItemStack { 160 + count: 1, 161 + damage: 0, 162 + id: "minecraft:written_book", 163 + }, 164 + tag: WrittenBookTag { 165 + base: BaseTag { 166 + display: None, 167 + }, 168 + author: "kokiriglade", 169 + title: "in a shulker box", 170 + generation: Original, 171 + resolved: false, 172 + pages: [ 173 + Object( 174 + ComponentObject { 175 + text: Some( 176 + TextComponent { 177 + text: "this one is in a shulker box!! wow!!!", 178 + }, 179 + ), 180 + translation: None, 181 + score: None, 182 + selector: None, 183 + keybind: None, 184 + nbt: None, 185 + object: None, 186 + formatting: ComponentFormatting { 187 + color: None, 188 + font: None, 189 + bold: None, 190 + italic: None, 191 + underlined: None, 192 + strikethrough: None, 193 + obfuscated: None, 194 + }, 195 + children: [], 196 + }, 197 + ), 198 + ], 199 + }, 200 + }, 201 + ), 202 + slot: 2, 203 + }, 204 + ], 205 + }, 107 206 }, 108 - ), 109 - slot: 5, 110 - }, 111 - ], 112 - ender_chest: [], 113 - }, 114 - ) 207 + }, 208 + ), 209 + slot: 1, 210 + }, 211 + InventoryItemStack { 212 + item: Base( 213 + BaseItemStack { 214 + count: 1, 215 + damage: 0, 216 + id: "minecraft:stone", 217 + }, 218 + ), 219 + slot: 2, 220 + }, 221 + InventoryItemStack { 222 + item: Base( 223 + BaseItemStack { 224 + count: 1, 225 + damage: 0, 226 + id: "minecraft:reeds", 227 + }, 228 + ), 229 + slot: 4, 230 + }, 231 + InventoryItemStack { 232 + item: Base( 233 + BaseItemStack { 234 + count: 1, 235 + damage: 0, 236 + id: "minecraft:golden_chestplate", 237 + }, 238 + ), 239 + slot: 5, 240 + }, 241 + ], 242 + ender_chest: [], 243 + }
+179
nara_slurper_1_12_world/tests/snapshots/data__playerdata_extract.snap
··· 1 + --- 2 + source: nara_slurper_1_12_world/tests/data.rs 3 + expression: extract_books_from_playerdata(&get_playerdata()) 4 + --- 5 + [ 6 + WrittenBookStack { 7 + base: BaseItemStack { 8 + count: 1, 9 + damage: 0, 10 + id: "minecraft:written_book", 11 + }, 12 + tag: WrittenBookTag { 13 + base: BaseTag { 14 + display: None, 15 + }, 16 + author: "kokiriglade", 17 + title: "just a book", 18 + generation: Original, 19 + resolved: false, 20 + pages: [ 21 + Array( 22 + [ 23 + Object( 24 + ComponentObject { 25 + text: Some( 26 + TextComponent { 27 + text: "Hello, World!\n\n", 28 + }, 29 + ), 30 + translation: None, 31 + score: None, 32 + selector: None, 33 + keybind: None, 34 + nbt: None, 35 + object: None, 36 + formatting: ComponentFormatting { 37 + color: None, 38 + font: None, 39 + bold: None, 40 + italic: None, 41 + underlined: None, 42 + strikethrough: None, 43 + obfuscated: None, 44 + }, 45 + children: [], 46 + }, 47 + ), 48 + Object( 49 + ComponentObject { 50 + text: Some( 51 + TextComponent { 52 + text: "This is red text!", 53 + }, 54 + ), 55 + translation: None, 56 + score: None, 57 + selector: None, 58 + keybind: None, 59 + nbt: None, 60 + object: None, 61 + formatting: ComponentFormatting { 62 + color: Some( 63 + Named( 64 + Red, 65 + ), 66 + ), 67 + font: None, 68 + bold: None, 69 + italic: None, 70 + underlined: None, 71 + strikethrough: None, 72 + obfuscated: None, 73 + }, 74 + children: [], 75 + }, 76 + ), 77 + Object( 78 + ComponentObject { 79 + text: Some( 80 + TextComponent { 81 + text: "\n\n", 82 + }, 83 + ), 84 + translation: None, 85 + score: None, 86 + selector: None, 87 + keybind: None, 88 + nbt: None, 89 + object: None, 90 + formatting: ComponentFormatting { 91 + color: None, 92 + font: None, 93 + bold: None, 94 + italic: None, 95 + underlined: None, 96 + strikethrough: None, 97 + obfuscated: None, 98 + }, 99 + children: [], 100 + }, 101 + ), 102 + Object( 103 + ComponentObject { 104 + text: Some( 105 + TextComponent { 106 + text: "This is blue :^)", 107 + }, 108 + ), 109 + translation: None, 110 + score: None, 111 + selector: None, 112 + keybind: None, 113 + nbt: None, 114 + object: None, 115 + formatting: ComponentFormatting { 116 + color: Some( 117 + Named( 118 + Blue, 119 + ), 120 + ), 121 + font: None, 122 + bold: None, 123 + italic: None, 124 + underlined: None, 125 + strikethrough: None, 126 + obfuscated: None, 127 + }, 128 + children: [], 129 + }, 130 + ), 131 + ], 132 + ), 133 + ], 134 + }, 135 + }, 136 + WrittenBookStack { 137 + base: BaseItemStack { 138 + count: 1, 139 + damage: 0, 140 + id: "minecraft:written_book", 141 + }, 142 + tag: WrittenBookTag { 143 + base: BaseTag { 144 + display: None, 145 + }, 146 + author: "kokiriglade", 147 + title: "in a shulker box", 148 + generation: Original, 149 + resolved: false, 150 + pages: [ 151 + Object( 152 + ComponentObject { 153 + text: Some( 154 + TextComponent { 155 + text: "this one is in a shulker box!! wow!!!", 156 + }, 157 + ), 158 + translation: None, 159 + score: None, 160 + selector: None, 161 + keybind: None, 162 + nbt: None, 163 + object: None, 164 + formatting: ComponentFormatting { 165 + color: None, 166 + font: None, 167 + bold: None, 168 + italic: None, 169 + underlined: None, 170 + strikethrough: None, 171 + obfuscated: None, 172 + }, 173 + children: [], 174 + }, 175 + ), 176 + ], 177 + }, 178 + }, 179 + ]
+3 -1
nara_text/Cargo.toml nara_core/Cargo.toml
··· 1 1 [package] 2 - name = "nara_text" 2 + name = "nara_core" 3 3 version.workspace = true 4 4 edition.workspace = true 5 5 6 6 [dependencies] 7 7 serde.workspace = true 8 + serde_json.workspace = true 8 9 serde_with.workspace = true 10 + sha1.workspace = true 9 11 10 12 [dev-dependencies] 11 13 insta.workspace = true
+1 -1
nara_text/README.md nara_core/README.md
··· 1 - # nara_text 1 + # nara_core 2 2 3 3 This is intended to be a 1:1 model of Minecraft: Java Edition's [text component format][text]. 4 4
nara_text/src/color.rs nara_core/src/color.rs
-135
nara_text/src/lib.rs
··· 1 - use serde::{Deserialize, Serialize}; 2 - 3 - pub mod color; 4 - pub mod profile; 5 - 6 - pub const LEGACY_SYMBOL: char = '§'; 7 - 8 - #[derive(Debug, Serialize, Deserialize)] 9 - #[serde(untagged)] 10 - pub enum Component { 11 - String(String), 12 - Object(ComponentObject), 13 - Array(Vec<Component>), 14 - } 15 - 16 - #[serde_with::skip_serializing_none] 17 - #[derive(Debug, Serialize, Deserialize)] 18 - pub struct ComponentObject { 19 - #[serde(flatten)] 20 - text: Option<TextComponent>, 21 - #[serde(flatten)] 22 - translation: Option<TranslationComponent>, 23 - // DO NOT FLATTEN THE SCORE COMPONENT 24 - score: Option<ScoreComponent>, 25 - #[serde(flatten)] 26 - selector: Option<SelectorComponent>, 27 - #[serde(flatten)] 28 - keybind: Option<KeybindComponent>, 29 - #[serde(flatten)] 30 - nbt: Option<NbtComponent>, 31 - #[serde(flatten)] 32 - object: Option<ObjectComponent>, 33 - #[serde(flatten)] 34 - formatting: ComponentFormatting, 35 - #[serde(default, rename = "extra", skip_serializing_if = "Vec::is_empty")] 36 - children: Vec<Component>, 37 - } 38 - 39 - // TODO(kokiriglade): make this a struct later 40 - pub type Identifier = String; 41 - 42 - #[serde_with::skip_serializing_none] 43 - #[derive(Debug, Serialize, Deserialize)] 44 - pub struct ComponentFormatting { 45 - color: Option<color::Color>, 46 - font: Option<Identifier>, 47 - bold: Option<bool>, 48 - italic: Option<bool>, 49 - underlined: Option<bool>, 50 - strikethrough: Option<bool>, 51 - obfuscated: Option<bool>, 52 - } 53 - 54 - #[derive(Debug, Serialize, Deserialize)] 55 - pub struct TextComponent { 56 - text: String, 57 - } 58 - 59 - #[serde_with::skip_serializing_none] 60 - #[derive(Debug, Serialize, Deserialize)] 61 - pub struct TranslationComponent { 62 - translate: String, 63 - fallback: Option<String>, 64 - #[serde(default, skip_serializing_if = "Vec::is_empty")] 65 - with: Vec<Component>, 66 - } 67 - 68 - #[serde_with::skip_serializing_none] 69 - #[derive(Debug, Serialize, Deserialize)] 70 - pub struct ScoreComponent { 71 - name: String, 72 - objective: String, 73 - } 74 - 75 - #[serde_with::skip_serializing_none] 76 - #[derive(Debug, Serialize, Deserialize)] 77 - pub struct SelectorComponent { 78 - selector: String, 79 - separator: Option<Box<Component>>, 80 - } 81 - 82 - #[serde_with::skip_serializing_none] 83 - #[derive(Debug, Serialize, Deserialize)] 84 - pub struct KeybindComponent { 85 - keybind: String, 86 - } 87 - 88 - #[derive(Debug, Serialize, Deserialize)] 89 - #[serde(rename_all = "snake_case")] 90 - pub enum NbtComponentSource { 91 - Block, 92 - Entity, 93 - Storage, 94 - } 95 - 96 - #[serde_with::skip_serializing_none] 97 - #[derive(Debug, Serialize, Deserialize)] 98 - pub struct NbtComponent { 99 - source: Option<NbtComponentSource>, 100 - #[serde(rename = "nbt")] 101 - path: String, 102 - interpret: Option<bool>, 103 - separator: Option<Box<Component>>, 104 - entity: Option<String>, 105 - block: Option<String>, 106 - storage: Option<Identifier>, 107 - } 108 - 109 - #[derive(Debug, Serialize, Deserialize)] 110 - #[serde(tag = "object", rename_all = "snake_case")] 111 - pub enum ObjectComponent { 112 - Atlas(AtlasObject), 113 - Player(PlayerObject), 114 - } 115 - 116 - #[serde_with::skip_serializing_none] 117 - #[derive(Debug, Serialize, Deserialize)] 118 - pub struct AtlasObject { 119 - atlas: Option<Identifier>, 120 - sprite: Identifier, 121 - } 122 - 123 - #[derive(Debug, Serialize, Deserialize)] 124 - #[serde(untagged)] 125 - pub enum PlayerProfileOrName { 126 - Profile(profile::PlayerProfile), 127 - Name(String), 128 - } 129 - 130 - #[serde_with::skip_serializing_none] 131 - #[derive(Debug, Serialize, Deserialize)] 132 - pub struct PlayerObject { 133 - player: PlayerProfileOrName, 134 - hat: Option<bool>, 135 - }
+6 -8
nara_text/src/profile.rs nara_core/src/profile.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 2 3 - use crate::Identifier; 4 - 5 3 #[serde_with::skip_serializing_none] 6 - #[derive(Debug, Serialize, Deserialize)] 4 + #[derive(Debug, Clone, Serialize, Deserialize)] 7 5 pub struct PlayerProfile { 8 6 name: Option<String>, 9 7 id: Option<[i32; 4]>, // TODO(kokiriglade): use an actual UUID type 10 8 #[serde(default, skip_serializing_if = "Vec::is_empty")] 11 9 properties: Vec<PlayerProfileProperty>, 12 - texture: Option<Identifier>, 13 - cape: Option<Identifier>, 14 - elytra: Option<Identifier>, 10 + texture: Option<String>, 11 + cape: Option<String>, 12 + elytra: Option<String>, 15 13 model: Option<PlayerModel>, 16 14 } 17 15 18 16 #[serde_with::skip_serializing_none] 19 - #[derive(Debug, Serialize, Deserialize)] 17 + #[derive(Debug, Clone, Serialize, Deserialize)] 20 18 pub struct PlayerProfileProperty { 21 19 name: String, 22 20 value: String, 23 21 signature: Option<String>, 24 22 } 25 23 26 - #[derive(Debug, Serialize, Deserialize)] 24 + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 27 25 #[serde(rename_all = "snake_case")] 28 26 pub enum PlayerModel { 29 27 Slim,
nara_text/tests/fixtures/keybind.json nara_core/tests/fixtures/keybind.json
nara_text/tests/fixtures/nbt_block.json nara_core/tests/fixtures/nbt_block.json
nara_text/tests/fixtures/nbt_entity.json nara_core/tests/fixtures/nbt_entity.json
nara_text/tests/fixtures/nbt_storage.json nara_core/tests/fixtures/nbt_storage.json
nara_text/tests/fixtures/object_atlas.json nara_core/tests/fixtures/object_atlas.json
nara_text/tests/fixtures/object_player.json nara_core/tests/fixtures/object_player.json
nara_text/tests/fixtures/object_player_profile.json nara_core/tests/fixtures/object_player_profile.json
nara_text/tests/fixtures/score.json nara_core/tests/fixtures/score.json
nara_text/tests/fixtures/selector.json nara_core/tests/fixtures/selector.json
nara_text/tests/fixtures/text_plain.json nara_core/tests/fixtures/text_plain.json
nara_text/tests/fixtures/text_tagged.json nara_core/tests/fixtures/text_tagged.json
nara_text/tests/fixtures/translation.json nara_core/tests/fixtures/translation.json
+1 -1
nara_text/tests/keybind.rs nara_core/tests/keybind.rs
··· 1 - use nara_text::Component; 1 + use nara_core::component::Component; 2 2 3 3 const FIXTURE: &str = include_str!("fixtures/keybind.json"); 4 4
+1 -1
nara_text/tests/nbt.rs nara_core/tests/nbt.rs
··· 1 - use nara_text::Component; 1 + use nara_core::component::Component; 2 2 3 3 const BLOCK_FIXTURE: &str = include_str!("fixtures/nbt_block.json"); 4 4 const ENTITY_FIXTURE: &str = include_str!("fixtures/nbt_entity.json");
+1 -1
nara_text/tests/object.rs nara_core/tests/object.rs
··· 1 - use nara_text::Component; 1 + use nara_core::component::Component; 2 2 3 3 const ATLAS_FIXTURE: &str = include_str!("fixtures/object_atlas.json"); 4 4 const PLAYER_FIXTURE: &str = include_str!("fixtures/object_player.json");
+1 -1
nara_text/tests/score.rs nara_core/tests/score.rs
··· 1 - use nara_text::Component; 1 + use nara_core::component::Component; 2 2 3 3 const FIXTURE: &str = include_str!("fixtures/score.json"); 4 4
+1 -1
nara_text/tests/selector.rs nara_core/tests/selector.rs
··· 1 - use nara_text::Component; 1 + use nara_core::component::Component; 2 2 3 3 const FIXTURE: &str = include_str!("fixtures/selector.json"); 4 4
+1 -1
nara_text/tests/snapshots/keybind__keybind.snap nara_core/tests/snapshots/keybind__keybind.snap
··· 1 1 --- 2 - source: nara_text/tests/keybind.rs 2 + source: nara_core/tests/keybind.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/snapshots/nbt__block.snap nara_core/tests/snapshots/nbt__block.snap
··· 1 1 --- 2 - source: nara_text/tests/nbt.rs 2 + source: nara_core/tests/nbt.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/snapshots/nbt__entity.snap nara_core/tests/snapshots/nbt__entity.snap
··· 1 1 --- 2 - source: nara_text/tests/nbt.rs 2 + source: nara_core/tests/nbt.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/snapshots/nbt__storage.snap nara_core/tests/snapshots/nbt__storage.snap
··· 1 1 --- 2 - source: nara_text/tests/nbt.rs 2 + source: nara_core/tests/nbt.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/snapshots/object__atlas.snap nara_core/tests/snapshots/object__atlas.snap
··· 1 1 --- 2 - source: nara_text/tests/object.rs 2 + source: nara_core/tests/object.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/snapshots/object__player.snap nara_core/tests/snapshots/object__player.snap
··· 1 1 --- 2 - source: nara_text/tests/object.rs 2 + source: nara_core/tests/object.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/snapshots/object__player_profile.snap nara_core/tests/snapshots/object__player_profile.snap
··· 1 1 --- 2 - source: nara_text/tests/object.rs 2 + source: nara_core/tests/object.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/snapshots/score__score.snap nara_core/tests/snapshots/score__score.snap
··· 1 1 --- 2 - source: nara_text/tests/score.rs 2 + source: nara_core/tests/score.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/snapshots/selector__selector.snap nara_core/tests/snapshots/selector__selector.snap
··· 1 1 --- 2 - source: nara_text/tests/selector.rs 2 + source: nara_core/tests/selector.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/snapshots/text__plain.snap nara_core/tests/snapshots/text__plain.snap
··· 1 1 --- 2 - source: nara_text/tests/text.rs 2 + source: nara_core/tests/text.rs 3 3 expression: parsed 4 4 --- 5 5 String(
+1 -1
nara_text/tests/snapshots/text__tagged.snap nara_core/tests/snapshots/text__tagged.snap
··· 1 1 --- 2 - source: nara_text/tests/text.rs 2 + source: nara_core/tests/text.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/snapshots/translation__translation.snap nara_core/tests/snapshots/translation__translation.snap
··· 1 1 --- 2 - source: nara_text/tests/translation.rs 2 + source: nara_core/tests/translation.rs 3 3 expression: parsed 4 4 --- 5 5 Object(
+1 -1
nara_text/tests/text.rs nara_core/tests/text.rs
··· 1 - use nara_text::Component; 1 + use nara_core::component::Component; 2 2 3 3 const PLAIN_FIXTURE: &str = include_str!("fixtures/text_plain.json"); 4 4 const TAGGED_FIXTURE: &str = include_str!("fixtures/text_tagged.json");
+1 -1
nara_text/tests/translation.rs nara_core/tests/translation.rs
··· 1 - use nara_text::Component; 1 + use nara_core::component::Component; 2 2 3 3 const FIXTURE: &str = include_str!("fixtures/translation.json"); 4 4
+47 -217
src/library.rs
··· 1 - use std::time::{SystemTime, SystemTimeError}; 2 - 3 1 use ahash::{AHashMap, AHashSet}; 4 2 use lru::LruCache; 3 + use nara_core::{ 4 + book::{Book, BookHash, BookSource}, 5 + component::Component, 6 + }; 5 7 use smol_str::SmolStr; 6 8 use std::{cell::RefCell, num::NonZeroUsize}; 7 9 use strsim::jaro_winkler; 8 10 9 - use serde::{Deserialize, Serialize}; 10 - 11 - /// Item model types parsed from realm data. 12 - pub mod item; 13 - pub mod text; 14 - 15 - use item::{ItemStack, WrittenBookTag}; 16 - use text::{Component, normalize_page, scrub_component, scrub_unwanted_glyphs}; 17 - 18 - pub type BookHash = [u8; 20]; 19 11 pub type BookId = usize; 20 12 21 - /// Raw realm export data (top-level list of items). 22 - #[derive(Debug, Serialize, Deserialize)] 23 - pub struct Realm { 24 - pub realm: Vec<ItemStack>, 25 - pub realm_version: String, 26 - } 27 - 28 13 /// In-memory index of all books with lookup and fuzzy search helpers. 29 14 #[derive(Debug)] 30 15 pub struct Library { 31 - books: Vec<WrittenBookTag>, 16 + books: Vec<Book>, 32 17 33 18 by_hash: AHashMap<BookHash, BookId>, 34 - location_by_hash: AHashMap<BookHash, SmolStr>, 19 + source_by_hash: AHashMap<BookHash, BookSource>, 35 20 by_category: AHashMap<SmolStr, Vec<BookId>>, 36 21 by_author_lc: AHashMap<SmolStr, Vec<BookId>>, 37 22 ··· 67 52 limit: usize, 68 53 } 69 54 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 55 impl Library { 83 - /// Builds a library index from a realm export. 84 - pub fn new( 85 - realm: Realm, 86 - content_threshold: f64, 87 - author_threshold: f64, 88 - title_threshold: f64, 89 - warn_duplicates: bool, 90 - warn_empty: bool, 91 - filter_empty_books: bool, 92 - ) -> Result<Library> { 93 - if realm.realm_version != "0.2" { 94 - return Err(LibraryError::UnsupportedRealmVersion( 95 - realm.realm_version, 96 - )); 97 - } 98 - 99 - let start = SystemTime::now(); 100 - let mut library = Library { 101 - books: Vec::new(), 102 - by_hash: AHashMap::new(), 103 - location_by_hash: AHashMap::new(), 104 - by_category: AHashMap::new(), 105 - by_author_lc: AHashMap::new(), 106 - norm_title: Vec::new(), 107 - norm_author: Vec::new(), 108 - norm_contents: Vec::new(), 109 - tri_title: AHashMap::new(), 110 - tri_author: AHashMap::new(), 111 - tri_contents: AHashMap::new(), 112 - content_threshold, 113 - author_threshold, 114 - title_threshold, 115 - cache_books_by_author: RefCell::new(new_lru(CACHE_BY_AUTHOR_CAP)), 116 - cache_books_in_category: RefCell::new(new_lru( 117 - CACHE_BY_CATEGORY_CAP, 118 - )), 119 - cache_fuzzy_title: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 120 - cache_fuzzy_author: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 121 - cache_fuzzy_contents: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 122 - cache_fuzzy_all: RefCell::new(new_lru(CACHE_FUZZY_CAP)), 123 - duplicate_books_filtered: 0, 124 - empty_books_filtered: 0, 125 - }; 126 - 127 - fn traverse_items( 128 - items: Vec<ItemStack>, 129 - current_location: Option<SmolStr>, 130 - library: &mut Library, 131 - warn_duplicates: bool, 132 - warn_empty: bool, 133 - filter_empty_books: bool, 134 - ) -> Result<()> { 135 - for item in items { 136 - match item { 137 - ItemStack::WrittenBook { tag, .. } => { 138 - library.add_book( 139 - tag, 140 - current_location.clone(), 141 - warn_duplicates, 142 - warn_empty, 143 - filter_empty_books, 144 - )?; 145 - } 146 - ItemStack::ShulkerBox { tag, .. } => { 147 - let shulker_location = tag 148 - .display 149 - .as_ref() 150 - .and_then(|d| d.name.as_deref()) 151 - .map(SmolStr::new); 152 - 153 - let next_location = shulker_location 154 - .or_else(|| current_location.clone()); 155 - traverse_items( 156 - tag.block_entity.items, 157 - next_location, 158 - library, 159 - warn_duplicates, 160 - warn_empty, 161 - filter_empty_books, 162 - )?; 163 - } 164 - } 165 - } 166 - Ok(()) 167 - } 168 - 169 - traverse_items( 170 - realm.realm, 171 - None, 172 - &mut library, 173 - warn_duplicates, 174 - warn_empty, 175 - filter_empty_books, 176 - )?; 177 - 178 - let elapsed_ms = SystemTime::now().duration_since(start)?.as_nanos() 179 - as f64 180 - / 1_000_000.0; 181 - tracing::info!( 182 - "Indexed {0} books in {1} categories in {2}ms (filtered {3} duplicates, {4} empty)", 183 - library.books.len(), 184 - library.by_category.keys().len(), 185 - elapsed_ms, 186 - library.duplicate_books_filtered, 187 - library.empty_books_filtered, 188 - ); 189 - Ok(library) 190 - } 191 - 192 56 /// Inserts a book and updates all indices and caches. 193 57 fn add_book( 194 58 &mut self, 195 - mut book: WrittenBookTag, 196 - location: Option<SmolStr>, 59 + book: Book, 197 60 warn_duplicates: bool, 198 61 warn_empty: bool, 199 62 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 - } 63 + ) { 206 64 if filter_empty_books && book_plain_text_empty(&book) { 207 65 if warn_empty { 208 66 tracing::warn!( 209 - "Skipping empty book in location {0:?}: {1} by {2}", 210 - location, 211 - book.title, 212 - book.author 67 + "Skipping empty book with source {0:?}: {1} by {2}", 68 + book.metadata.source, 69 + book.content.title, 70 + book.content.author 213 71 ); 214 72 } 215 73 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)); 74 + return; 225 75 }; 226 76 227 77 let h = book.hash(); 228 78 if self.by_hash.contains_key(&h) { 229 79 if warn_duplicates { 230 - let existing_book_location = 231 - self.location_by_hash.get(&h).expect("book to exist"); 80 + let existing_book_source = 81 + self.source_by_hash.get(&h).expect("book to exist"); 232 82 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 83 + "Duplicate book with source {0:?}: {1} by {2} [already one with {3:?}]", 84 + book.metadata.source, 85 + book.content.title, 86 + book.content.author, 87 + existing_book_source 238 88 ); 239 89 } 240 90 self.duplicate_books_filtered += 1; 241 - return Ok(()); 91 + return; 242 92 } 243 93 244 94 let id = self.books.len(); 95 + 96 + let source = book.metadata.source.clone(); 245 97 self.books.push(book); 246 98 99 + let category: SmolStr = "todo".into(); // TODO(kokiriglade): classify from contents 100 + 247 101 // indices... 248 102 self.by_hash.insert(h, id); 249 103 self.by_category 250 104 .entry(category.clone()) 251 105 .or_default() 252 106 .push(id); 253 - self.location_by_hash.insert(h, location); 107 + self.source_by_hash.insert(h, source); 254 108 255 - let author_lc = SmolStr::new(normalize(&self.books[id].author)); 109 + let author_lc = SmolStr::new(normalize(&self.books[id].content.author)); 256 110 if !author_lc.is_empty() { 257 111 self.by_author_lc.entry(author_lc).or_default().push(id); 258 112 } 259 113 260 114 // normalized blobs (for scoring) 261 - self.norm_title.push(normalize(&self.books[id].title)); 262 - self.norm_author.push(normalize(&self.books[id].author)); 115 + self.norm_title 116 + .push(normalize(&self.books[id].content.title)); 117 + self.norm_author 118 + .push(normalize(&self.books[id].content.author)); 263 119 self.norm_contents 264 - .push(normalize_contents(&self.books[id].pages)); 120 + .push(normalize_contents(&self.books[id].content.pages)); 265 121 266 122 // candidate-generation indices 267 123 index_trigrams(&mut self.tri_title, id, &self.norm_title[id]); ··· 274 130 self.cache_fuzzy_author.borrow_mut().clear(); 275 131 self.cache_fuzzy_contents.borrow_mut().clear(); 276 132 self.cache_fuzzy_all.borrow_mut().clear(); 277 - 278 - Ok(()) 279 133 } 280 134 281 135 /// Looks up a book by its content hash. 282 136 #[inline] 283 - pub fn book_by_hash(&self, hash: BookHash) -> Option<&WrittenBookTag> { 137 + pub fn book_by_hash(&self, hash: BookHash) -> Option<&Book> { 284 138 self.by_hash.get(&hash).map(|&id| &self.books[id]) 285 139 } 286 140 ··· 289 143 pub fn books_by_author<'a>( 290 144 &'a self, 291 145 author: &str, 292 - ) -> impl Iterator<Item = &'a WrittenBookTag> + 'a { 146 + ) -> impl Iterator<Item = &'a Book> + 'a { 293 147 let key = SmolStr::new(normalize(author)); 294 148 let ids = if key.is_empty() { 295 149 Vec::new() ··· 313 167 pub fn books_in_category<'a>( 314 168 &'a self, 315 169 category: &str, 316 - ) -> impl Iterator<Item = &'a WrittenBookTag> + 'a { 170 + ) -> impl Iterator<Item = &'a Book> + 'a { 317 171 let key = SmolStr::new(category); 318 172 let ids = if key.is_empty() { 319 173 Vec::new() ··· 333 187 } 334 188 335 189 /// Fuzzy search over normalized titles. 336 - pub fn fuzzy_title( 337 - &self, 338 - query: &str, 339 - limit: usize, 340 - ) -> Vec<(&WrittenBookTag, f64)> { 190 + pub fn fuzzy_title(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> { 341 191 let key = SmolStr::new(normalize(query)); 342 192 if key.is_empty() || limit == 0 { 343 193 return Vec::new(); ··· 373 223 } 374 224 375 225 /// Fuzzy search over normalized author names. 376 - pub fn fuzzy_author( 377 - &self, 378 - query: &str, 379 - limit: usize, 380 - ) -> Vec<(&WrittenBookTag, f64)> { 226 + pub fn fuzzy_author(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> { 381 227 let key = SmolStr::new(normalize(query)); 382 228 if key.is_empty() || limit == 0 { 383 229 return Vec::new(); ··· 420 266 &self, 421 267 query: &str, 422 268 limit: usize, 423 - ) -> Vec<(&WrittenBookTag, f64)> { 269 + ) -> Vec<(&Book, f64)> { 424 270 let key = SmolStr::new(normalize(query)); 425 271 if key.is_empty() || limit == 0 { 426 272 return Vec::new(); ··· 459 305 } 460 306 461 307 /// Combined fuzzy search (title + author + contents). 462 - pub fn fuzzy( 463 - &self, 464 - query: &str, 465 - limit: usize, 466 - ) -> Vec<(&WrittenBookTag, f64)> { 308 + pub fn fuzzy(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> { 467 309 let key = SmolStr::new(normalize(query)); 468 310 if key.is_empty() || limit == 0 { 469 311 return Vec::new(); ··· 540 382 541 383 /// Returns a list of all books in the library. 542 384 #[inline] 543 - pub fn all_books<'a>( 544 - &'a self, 545 - ) -> impl Iterator<Item = &'a WrittenBookTag> + 'a { 385 + pub fn all_books<'a>(&'a self) -> impl Iterator<Item = &'a Book> + 'a { 546 386 self.books.iter() 547 387 } 548 388 ··· 555 395 .collect() 556 396 } 557 397 558 - /// Returns the location string for a book hash, if present. 398 + /// Returns the source for a book hash, if present. 559 399 #[inline] 560 - pub fn location_for_hash(&self, hash: &BookHash) -> Option<&SmolStr> { 561 - self.location_by_hash.get(hash) 400 + pub fn source_for_hash(&self, hash: &BookHash) -> Option<&BookSource> { 401 + self.source_by_hash.get(hash) 562 402 } 563 403 } 564 404 ··· 568 408 s.to_lowercase() 569 409 } 570 410 571 - fn book_plain_text_empty(book: &WrittenBookTag) -> bool { 572 - book.pages.is_empty() 411 + fn book_plain_text_empty(book: &Book) -> bool { 412 + book.content.pages.is_empty() 573 413 || book 414 + .content 574 415 .pages 575 416 .iter() 576 - .all(|page| normalize_page(page).trim().is_empty()) 417 + .all(|page| page.normalize().trim().is_empty()) 577 418 } 578 419 579 420 const CACHE_BY_AUTHOR_CAP: usize = 1024; ··· 583 424 /// Helper to build LRU caches with non-zero capacity. 584 425 fn new_lru<K: std::hash::Hash + Eq, V>(cap: usize) -> LruCache<K, V> { 585 426 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 427 } 598 428 599 429 const MAX_CONTENT_INDEX_CHARS: usize = 16_384;
-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 - }
+180 -188
src/library/text.rs nara_core/src/component.rs
··· 1 - use html_escape::encode_text; 2 - use serde::de::{ 3 - MapAccess, SeqAccess, Visitor, 4 - value::{MapAccessDeserializer, SeqAccessDeserializer}, 1 + use serde::{ 2 + Deserialize, Deserializer, Serialize, 3 + de::{ 4 + MapAccess, SeqAccess, Visitor, 5 + value::{MapAccessDeserializer, SeqAccessDeserializer}, 6 + }, 5 7 }; 6 - use serde::{Deserialize, Deserializer, Serialize}; 7 8 8 - /// A simplified representation of Minecraft 1.12.2 text components. 9 9 #[derive(Debug, Clone, Serialize)] 10 10 #[serde(untagged)] 11 11 pub enum Component { 12 12 String(String), 13 - Object(ComponentObject), 13 + Object(Box<ComponentObject>), 14 14 Array(Vec<Component>), 15 15 } 16 16 17 17 #[serde_with::skip_serializing_none] 18 18 #[derive(Debug, Clone, Serialize, Deserialize)] 19 19 pub struct ComponentObject { 20 - #[serde(default)] 21 - pub text: Option<String>, 22 - #[serde(default)] 23 - pub color: Option<String>, 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>, 24 62 #[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>, 63 + with: Vec<Component>, 30 64 } 31 65 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 - } 66 + #[serde_with::skip_serializing_none] 67 + #[derive(Debug, Clone, Serialize, Deserialize)] 68 + pub struct ScoreComponent { 69 + name: String, 70 + objective: String, 71 + } 38 72 39 - pub fn to_html(&self) -> String { 40 - let mut out = String::new(); 41 - self.push_html(&mut out, None); 42 - out 43 - } 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 + } 44 79 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 - } 80 + #[serde_with::skip_serializing_none] 81 + #[derive(Debug, Clone, Serialize, Deserialize)] 82 + pub struct KeybindComponent { 83 + keybind: String, 84 + } 68 85 69 - fn push_html<'a>(&'a self, out: &mut String, color: Option<&'a str>) { 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 + pub fn to_plain_text(&self) -> String { 70 137 match self { 71 - Component::String(s) => { 72 - push_text_with_color(out, s, color); 73 - } 138 + Component::String(s) => s.clone(), 74 139 Component::Array(items) => { 75 - for c in items { 76 - c.push_html(out, color); 77 - } 140 + items.iter().map(|c| c.to_plain_text()).collect() 78 141 } 79 142 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); 143 + let mut s = 144 + obj.text.as_ref().map_or(String::new(), |t| t.text.clone()); 145 + for child in &obj.children { 146 + s.push_str(&child.to_plain_text()); 88 147 } 89 - for c in &obj.extra { 90 - c.push_html(out, next_color); 91 - } 148 + s 92 149 } 93 150 } 94 151 } 95 - } 96 152 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 - } 153 + pub fn normalize(&self) -> String { 154 + let text = self.to_plain_text(); 155 + strip_section_codes(&text) 129 156 } 130 157 } 131 158 ··· 142 169 out.push(c); 143 170 } 144 171 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 172 } 152 173 153 174 /// Parses a page string into a component, handling stringified JSON. ··· 269 290 Component::Array(items.into_iter().map(expand_legacy).collect()) 270 291 } 271 292 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('§') 293 + if obj.translation.is_none() 294 + && let Some(ref tc) = obj.text 295 + && tc.text.contains('§') 276 296 { 277 297 let expanded = parse_legacy_section_text_with_color( 278 - &text, 279 - obj.color.clone(), 298 + &tc.text.clone(), 299 + obj.formatting.color.clone(), 280 300 ); 281 - return merge_expanded_with_extra(expanded, obj.extra); 301 + return merge_expanded_with_extra(expanded, obj.children); 282 302 } 283 - obj.extra = obj.extra.into_iter().map(expand_legacy).collect(); 284 - obj.with_ = obj.with_.into_iter().map(expand_legacy).collect(); 303 + obj.children = 304 + obj.children.into_iter().map(expand_legacy).collect(); 305 + if let Some(ref mut trans) = obj.translation { 306 + trans.with = trans.with.drain(..).map(expand_legacy).collect(); 307 + } 285 308 Component::Object(obj) 286 309 } 287 310 } ··· 307 330 } 308 331 } 309 332 333 + fn make_text_object( 334 + text: String, 335 + color: Option<crate::color::Color>, 336 + ) -> Component { 337 + Component::Object(Box::new(ComponentObject { 338 + text: Some(TextComponent { text }), 339 + translation: None, 340 + score: None, 341 + selector: None, 342 + keybind: None, 343 + nbt: None, 344 + object: None, 345 + formatting: ComponentFormatting { 346 + color, 347 + font: None, 348 + bold: None, 349 + italic: None, 350 + underlined: None, 351 + strikethrough: None, 352 + obfuscated: None, 353 + }, 354 + children: Vec::new(), 355 + })) 356 + } 357 + 310 358 fn parse_legacy_section_text(s: &str) -> Component { 311 359 parse_legacy_section_text_with_color(s, None) 312 360 } 313 361 314 362 fn parse_legacy_section_text_with_color( 315 363 s: &str, 316 - initial_color: Option<String>, 364 + initial_color: Option<crate::color::Color>, 317 365 ) -> Component { 318 366 let mut out: Vec<Component> = Vec::new(); 319 367 let mut buf = String::new(); 320 - let mut color: Option<String> = initial_color; 368 + let mut color: Option<crate::color::Color> = initial_color; 321 369 322 370 let mut chars = s.chars(); 323 371 while let Some(c) = chars.next() { ··· 325 373 && let Some(code) = chars.next() 326 374 { 327 375 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 - })); 376 + out.push(make_text_object( 377 + std::mem::take(&mut buf), 378 + color.clone(), 379 + )); 335 380 } 336 381 337 382 match legacy_color_name(code) { 338 - Some(name) => color = Some(name), 383 + Some(name) => { 384 + color = crate::color::Color::try_from(name).ok(); 385 + } 339 386 None => { 340 387 if code == LEGACY_RESET_LOWER || code == LEGACY_RESET_UPPER 341 388 { ··· 349 396 } 350 397 351 398 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 - })); 399 + out.push(make_text_object(buf, color)); 359 400 } 360 401 361 402 if out.is_empty() { ··· 395 436 const LEGACY_RESET_LOWER: char = 'r'; 396 437 const LEGACY_RESET_UPPER: char = 'R'; 397 438 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 439 impl<'de> Deserialize<'de> for Component { 439 440 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 440 441 where ··· 483 484 let obj = ComponentObject::deserialize( 484 485 MapAccessDeserializer::new(map), 485 486 )?; 486 - Ok(expand_legacy(Component::Object(obj))) 487 + Ok(expand_legacy(Component::Object(Box::new(obj)))) 487 488 } 488 489 } 489 490 ··· 498 499 #[test] 499 500 fn normalize_plain_text() { 500 501 let c = parse_component_from_str("hello world"); 501 - assert_eq!(normalize_page(&c), "hello world"); 502 + assert_eq!(c.normalize(), "hello world"); 502 503 } 503 504 504 505 #[test] 505 506 fn normalize_json_object() { 506 507 let c = parse_component_from_str(r#"{"text":"hello §aworld"}"#); 507 - assert_eq!(normalize_page(&c), "hello world"); 508 + assert_eq!(c.normalize(), "hello world"); 508 509 } 509 510 510 511 #[test] 511 512 fn normalize_json_array() { 512 513 let c = 513 514 parse_component_from_str(r#"[{"text":"hi "},{"text":"there"}]"#); 514 - assert_eq!(normalize_page(&c), "hi there"); 515 + assert_eq!(c.normalize(), "hi there"); 515 516 } 516 517 517 518 #[test] 518 519 fn normalize_stringified_json() { 519 520 let c = parse_component_from_str(r#""{\"text\":\"hello\"}""#); 520 - assert_eq!(normalize_page(&c), "hello"); 521 + assert_eq!(c.normalize(), "hello"); 521 522 } 522 523 523 524 #[test] ··· 536 537 #[test] 537 538 fn legacy_text_prefix() { 538 539 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"); 540 + assert_eq!(c.normalize(), "hello world"); 549 541 } 550 542 }
+2 -28
src/main.rs
··· 1 - use std::{fs, io::Cursor}; 2 - 3 1 use anyhow::Context; 4 2 use clap::Parser as _; 5 3 6 4 use crate::{ 7 5 cli::{Cli, Command, ServeArgs}, 8 - library::{Library, Realm}, 6 + library::Library, 9 7 web::start_webserver, 10 8 }; 11 9 ··· 36 34 } 37 35 38 36 fn build_library(args: &ServeArgs) -> anyhow::Result<Library> { 39 - let realm_path = &args.realm_path; 40 - 41 - if !fs::exists(realm_path) 42 - .context("Checking if supplied realm path exists")? 43 - { 44 - return Err(anyhow::anyhow!("File {:?} not found", realm_path)); 45 - } 46 - 47 - let bytes = fs::read(realm_path).context("Reading realm")?; 48 - let mut cursor: Cursor<&[u8]> = Cursor::new(&bytes); 49 - let realm = crab_nbt::serde::de::from_cursor::<Realm>(&mut cursor) 50 - .context("Reading realm NBT")?; 51 - 52 - let library = Library::new( 53 - realm, 54 - args.content_threshold, 55 - args.author_threshold, 56 - args.title_threshold, 57 - args.warn_duplicates, 58 - args.warn_empty, 59 - args.filter_empty_books, 60 - ) 61 - .context("Constructing library")?; 62 - 63 - Ok(library) 37 + todo!() 64 38 }
+6 -22
src/web/api.rs
··· 7 7 response::{IntoResponse, Response}, 8 8 routing::get, 9 9 }; 10 + use nara_core::book::Book; 10 11 use serde::{Deserialize, Serialize}; 11 12 12 - use crate::library::{self, Library, item::WrittenBookTag, text::Component}; 13 + use crate::library::Library; 13 14 14 15 #[derive(Clone)] 15 16 pub struct AppState { ··· 252 253 fn run_search( 253 254 library: &Library, 254 255 query: SearchQuery, 255 - search_fn: for<'a> fn( 256 - &'a Library, 257 - &str, 258 - usize, 259 - ) -> Vec<(&'a WrittenBookTag, f64)>, 256 + search_fn: for<'a> fn(&'a Library, &str, usize) -> Vec<(&'a Book, f64)>, 260 257 ) -> Result<Json<Vec<SearchResult>>, ApiError> { 261 258 let query_str = query.query.trim(); 262 259 if query_str.is_empty() { ··· 281 278 282 279 #[derive(Serialize)] 283 280 struct BookDetail { 284 - title: String, 285 - author: String, 286 - pages: Vec<Component>, 281 + book: Book, 287 282 hash: String, 288 - location: Option<String>, 289 - category: Option<String>, 290 283 } 291 284 292 - fn book_to_detail(book: &WrittenBookTag, library: &Library) -> BookDetail { 285 + fn book_to_detail(book: &Book, _library: &Library) -> BookDetail { 293 286 let hash = book.hash(); 294 287 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 288 301 289 BookDetail { 302 - title: book.title.clone(), 303 - author: book.author.clone(), 304 - pages: book.pages.clone(), 290 + book: book.clone(), 305 291 hash: hash_hex, 306 - location, 307 - category, 308 292 } 309 293 } 310 294
+30 -61
src/web/pages.rs
··· 11 11 extract::{Path, Query}, 12 12 response::Redirect, 13 13 }; 14 + use nara_core::book::{Book, BookSource}; 14 15 use serde::Deserialize; 15 16 use smol_str::SmolStr; 16 17 17 - use crate::{ 18 - library::{Library, category_from_location, item::WrittenBookTag}, 19 - web::TextureKind, 20 - }; 18 + use crate::{library::Library, web::TextureKind}; 21 19 22 20 #[derive(Debug, Template, WebTemplate)] 23 21 #[template(path = "index.html", whitespace = "minimize")] ··· 108 106 pub title: String, 109 107 pub author: String, 110 108 pub author_href: String, 111 - pub location: String, 112 - pub has_location: bool, 109 + pub source: BookSource, 113 110 pub category: String, 114 111 pub category_href: String, 115 - pub has_category: bool, 116 112 pub pages: Vec<PageView>, 117 113 pub page_count: usize, 118 114 } ··· 360 356 fn collect_candidates<'a>( 361 357 library: &'a Library, 362 358 params: &FilterParams, 363 - ) -> Vec<&'a WrittenBookTag> { 359 + ) -> Vec<&'a Book> { 364 360 if !params.query.is_empty() { 365 361 // Search results are narrowed by optional author/category filters. 366 362 return search_books( ··· 441 437 let mut counts: ahash::AHashMap<SmolStr, (SmolStr, usize)> = 442 438 ahash::AHashMap::new(); 443 439 for book in library.all_books() { 444 - let raw = book.author.trim(); 440 + let raw = book.content.author.trim(); 445 441 if raw.is_empty() { 446 442 continue; 447 443 } ··· 553 549 554 550 struct BookMeta { 555 551 author_href: String, 556 - location: String, 557 - has_location: bool, 558 552 category: String, 559 - has_category: bool, 560 553 category_href: String, 561 554 } 562 555 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 - }; 556 + fn book_meta(book: &Book, library: &Library) -> BookMeta { 557 + // let hash = book.hash(); 558 + let category: String = todo!(); 559 + 560 + let category_href = 561 + format!("/?category={0}", encode_query_component(&category)); 583 562 let author_href = 584 - format!("/?author={0}", encode_query_component(&book.author)); 563 + format!("/?author={0}", encode_query_component(&book.content.author)); 585 564 586 565 BookMeta { 587 566 author_href, 588 - location, 589 - has_location, 590 567 category, 591 - has_category, 592 568 category_href, 593 569 } 594 570 } 595 571 596 - fn book_card(book: &WrittenBookTag) -> BookCard { 572 + fn book_card(book: &Book) -> BookCard { 597 573 let hash_hex = hex::encode(book.hash()); 598 574 let author_href = 599 - format!("/?author={0}", encode_query_component(&book.author)); 575 + format!("/?author={0}", encode_query_component(&book.content.author)); 600 576 601 577 BookCard { 602 - title: book.title.clone(), 603 - author: book.author.clone(), 578 + title: book.content.title.clone(), 579 + author: book.content.author.clone(), 604 580 author_href, 605 581 hash: hash_hex.clone(), 606 582 detail_href: format!("/book/{0}", hash_hex), 607 583 } 608 584 } 609 585 610 - fn book_detail(book: &WrittenBookTag, library: &Library) -> BookDetail { 586 + fn book_detail(book: &Book, library: &Library) -> BookDetail { 611 587 let meta = book_meta(book, library); 612 588 let pages = book 589 + .content 613 590 .pages 614 591 .iter() 615 592 .enumerate() 616 593 .map(|(idx, page)| PageView { 617 594 index: idx + 1, 618 - html: HtmlText(page.to_html()), 595 + html: HtmlText(page.to_plain_text()), // TODO(kokiriglade): pass to_html or similar here 619 596 }) 620 597 .collect(); 621 598 622 599 BookDetail { 623 - title: book.title.clone(), 624 - author: book.author.clone(), 600 + title: book.content.title.clone(), 601 + author: book.content.author.clone(), 625 602 author_href: meta.author_href, 626 - location: meta.location, 627 - has_location: meta.has_location, 603 + source: todo!(), 628 604 category: meta.category, 629 605 category_href: meta.category_href, 630 - has_category: meta.has_category, 631 606 pages, 632 - page_count: book.pages.len(), 607 + page_count: book.content.pages.len(), 633 608 } 634 609 } 635 610 ··· 661 636 scope: &str, 662 637 query: &str, 663 638 fetch: usize, 664 - ) -> Vec<&'a WrittenBookTag> { 639 + ) -> Vec<&'a Book> { 665 640 match scope { 666 641 "title" => search_title_relaxed(library, query, fetch), 667 642 "author" => library ··· 686 661 library: &'a Library, 687 662 query: &str, 688 663 fetch: usize, 689 - ) -> Vec<&'a WrittenBookTag> { 664 + ) -> Vec<&'a Book> { 690 665 let needle = normalize_query(query); 691 666 if needle.is_empty() || fetch == 0 { 692 667 return Vec::new(); ··· 696 671 let mut seen: AHashSet<[u8; 20]> = AHashSet::new(); 697 672 698 673 for book in library.all_books() { 699 - let title_norm = normalize_query(&book.title); 674 + let title_norm = normalize_query(&book.content.title); 700 675 if title_norm.contains(&needle) { 701 676 let hash = book.hash(); 702 677 if seen.insert(hash) { ··· 721 696 out 722 697 } 723 698 724 - fn matches_author(book: &WrittenBookTag, author_norm: Option<&str>) -> bool { 699 + fn matches_author(book: &Book, author_norm: Option<&str>) -> bool { 725 700 let Some(author_norm) = author_norm else { 726 701 return true; 727 702 }; 728 - book.author.trim().eq_ignore_ascii_case(author_norm) 703 + book.content.author.trim().eq_ignore_ascii_case(author_norm) 729 704 } 730 705 731 706 fn matches_category( 732 - book: &WrittenBookTag, 707 + book: &Book, 733 708 library: &Library, 734 709 category_norm: Option<&str>, 735 710 ) -> bool { 736 711 let Some(category_norm) = category_norm else { 737 712 return true; 738 713 }; 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) 714 + todo!() 746 715 } 747 716 748 717 fn parse_hash(hex_str: &str) -> Option<[u8; 20]> {
+53 -43
templates/book.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block title %}{{ book.title }} - nara{% endblock %} 1 + {% extends "base.html" %} {% block title %}{{ book.title }} - nara{% endblock %} 4 2 {% block head %} 5 - <link rel="icon" type="image/webp" href="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" /> 3 + <link 4 + rel="icon" 5 + type="image/webp" 6 + href="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" 7 + /> 6 8 <meta property="og:site_name" content="nara" /> 7 9 <meta property="og:type" content="article" /> 8 10 <meta property="og:title" content="{{ book.title }} - nara" /> 9 - <meta property="og:description" content="By {{ book.author }}. {{ book.page_count }} pages." /> 11 + <meta 12 + property="og:description" 13 + content="By {{ book.author }}. {{ book.page_count }} pages." 14 + /> 10 15 <meta name="twitter:card" content="summary_large_image" /> 11 16 <meta name="twitter:title" content="{{ book.title }} - nara" /> 12 - <meta name="twitter:description" content="By {{ book.author }}. {{ book.page_count }} pages." /> 13 - {% endblock %} 14 - 15 - {% block page_class %}page detail{% endblock %} 16 - {% block page_attrs %} style="--book-mask: url('/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}')"{% endblock %} 17 - 18 - {% block body %} 17 + <meta 18 + name="twitter:description" 19 + content="By {{ book.author }}. {{ book.page_count }} pages." 20 + /> 21 + {% endblock %} {% block page_class %}page detail{% endblock %} {% block 22 + page_attrs %} style="--book-mask: url('/assets/image/{{ texture_kind 23 + }}/written_book.webp?v={{ crate::VERSION }}')"{% endblock %} {% block body %} 19 24 <header class="hero detail-hero"> 20 - <div class="brand"> 21 - <span class="enchanted book-badge"> 22 - <img class="item" src="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" alt=""> 23 - </span> 24 - <div> 25 - <p class="eyebrow">Book Detail</p> 26 - <h1 class="font-minecraft">{{ book.title }}</h1> 27 - <p class="meta">by <a href="{{ book.author_href }}">{{ book.author }}</a></p> 28 - <div class="meta-row"> 29 - {% if book.has_category %} 30 - <a class="tag-link" href="{{ book.category_href }}"><span class="tag">{{ book.category }}</span></a> 31 - {% endif %} 32 - {% if book.has_location %} 33 - <span class="tag subtle">{{ book.location }}</span> 34 - {% endif %} 35 - <span class="tag subtle">{{ book.page_count }} pages</span> 36 - </div> 25 + <div class="brand"> 26 + <span class="enchanted book-badge"> 27 + <img 28 + class="item" 29 + src="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" 30 + alt="" 31 + /> 32 + </span> 33 + <div> 34 + <p class="eyebrow">Book Detail</p> 35 + <h1 class="font-minecraft">{{ book.title }}</h1> 36 + <p class="meta"> 37 + by <a href="{{ book.author_href }}">{{ book.author }}</a> 38 + </p> 39 + <div class="meta-row"> 40 + <a class="tag-link" href="{{ book.category_href }}" 41 + ><span class="tag">{{ book.category }}</span></a 42 + > 43 + <span class="tag subtle">{{ book.page_count }} pages</span> 44 + </div> 45 + </div> 37 46 </div> 38 - </div> 39 - <div class="detail-actions"> 40 - <a class="button ghost" href="{{ back_href }}">Back to list</a> 41 - </div> 47 + <div class="detail-actions"> 48 + <a class="button ghost" href="{{ back_href }}">Back to list</a> 49 + </div> 42 50 </header> 43 51 44 52 <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> 53 + <div class="pages"> 54 + {% for page in book.pages %} 55 + <article class="book-page"> 56 + <div class="book-page-sprite"> 57 + <p class="book-page-header font-minecraft"> 58 + Page {{ page.index }} of {{ book.page_count }} 59 + </p> 60 + <div class="book-page-text font-minecraft">{{ page.html }}</div> 61 + </div> 62 + </article> 63 + {% endfor %} 64 + </div> 55 65 </section> 56 66 {% endblock %}