tangled
alpha
login
or
join now
kokirigla.de
/
nara
0
fork
atom
online Minecraft written book viewer
0
fork
atom
overview
issues
pulls
pipelines
feat(*): a lot more work on abstraction
kokirigla.de
3 weeks ago
070328c3
36623306
verified
This commit was signed with the committer's
known signature
.
kokirigla.de
SSH Key Fingerprint:
SHA256:BlSEtD3ZoKT3iKveofI8gba+lZ9CEolKRM1Pzy3pAwg=
+985
-991
61 changed files
expand all
collapse all
unified
split
Cargo.lock
Cargo.toml
justfile
nara_1_12
src
lib.rs
nara_core
Cargo.toml
README.md
src
book.rs
color.rs
component.rs
lib.rs
profile.rs
tests
fixtures
keybind.json
nbt_block.json
nbt_entity.json
nbt_storage.json
object_atlas.json
object_player.json
object_player_profile.json
score.json
selector.json
text_plain.json
text_tagged.json
translation.json
keybind.rs
nbt.rs
object.rs
score.rs
selector.rs
snapshots
keybind__keybind.snap
nbt__block.snap
nbt__entity.snap
nbt__storage.snap
object__atlas.snap
object__player.snap
object__player_profile.snap
score__score.snap
selector__selector.snap
text__plain.snap
text__tagged.snap
translation__translation.snap
text.rs
translation.rs
nara_slurper_1_12_core
Cargo.toml
README.md
src
item.rs
lib.rs
nara_slurper_1_12_infinity
Cargo.toml
README.md
src
lib.rs
nara_slurper_1_12_world
Cargo.toml
src
lib.rs
tests
data.rs
snapshots
data__playerdata.snap
data__playerdata_extract.snap
nara_text
src
lib.rs
src
library
item.rs
library.rs
main.rs
web
api.rs
pages.rs
templates
book.html
+21
-11
Cargo.lock
···
1023
1023
"hex",
1024
1024
"html-escape",
1025
1025
"lru",
1026
1026
+
"nara_core",
1026
1027
"serde",
1027
1028
"serde_json",
1028
1029
"serde_with",
···
1037
1038
]
1038
1039
1039
1040
[[package]]
1040
1040
-
name = "nara_1_12"
1041
1041
+
name = "nara_core"
1041
1042
version = "0.1.0"
1042
1043
dependencies = [
1043
1043
-
"nara_text",
1044
1044
+
"insta",
1044
1045
"serde",
1046
1046
+
"serde_json",
1047
1047
+
"serde_with",
1048
1048
+
"sha1",
1045
1049
]
1046
1050
1047
1051
[[package]]
···
1055
1059
]
1056
1060
1057
1061
[[package]]
1058
1058
-
name = "nara_slurper_1_12_world"
1062
1062
+
name = "nara_slurper_1_12_core"
1059
1063
version = "0.1.0"
1060
1064
dependencies = [
1061
1061
-
"crab_nbt",
1062
1062
-
"insta",
1063
1063
-
"nara_1_12",
1064
1064
-
"nara_io",
1065
1065
-
"nara_text",
1065
1065
+
"nara_core",
1066
1066
"serde",
1067
1067
-
"serde_with",
1068
1067
]
1069
1068
1070
1069
[[package]]
1071
1071
-
name = "nara_text"
1070
1070
+
name = "nara_slurper_1_12_infinity"
1072
1071
version = "0.1.0"
1073
1072
dependencies = [
1073
1073
+
"nara_slurper_1_12_core",
1074
1074
+
"serde",
1075
1075
+
]
1076
1076
+
1077
1077
+
[[package]]
1078
1078
+
name = "nara_slurper_1_12_world"
1079
1079
+
version = "0.1.0"
1080
1080
+
dependencies = [
1081
1081
+
"crab_nbt",
1074
1082
"insta",
1083
1083
+
"nara_core",
1084
1084
+
"nara_io",
1085
1085
+
"nara_slurper_1_12_core",
1075
1086
"serde",
1076
1076
-
"serde_json",
1077
1087
"serde_with",
1078
1088
]
1079
1089
+5
-3
Cargo.toml
···
1
1
[workspace]
2
2
resolver = "3"
3
3
-
members = ["nara_io", "nara_1_12", "nara_slurper_1_12_world", "nara_text"]
3
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
22
-
nara_1_12 = { path = "nara_1_12" }
22
22
+
nara_slurper_1_12_core = { path = "nara_slurper_1_12_core" }
23
23
nara_io = { path = "nara_io" }
24
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
25
-
nara_text = { path = "nara_text" }
26
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
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
16
-
@cargo clippy
16
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
30
-
@cargo check --workspace
30
30
+
@cargo fmt --check
+2
-2
nara_1_12/Cargo.toml
nara_slurper_1_12_core/Cargo.toml
···
1
1
[package]
2
2
-
name = "nara_1_12"
2
2
+
name = "nara_slurper_1_12_core"
3
3
version.workspace = true
4
4
edition.workspace = true
5
5
6
6
[dependencies]
7
7
-
nara_text.workspace = true
7
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
1
-
# nara_1_12
1
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
1
-
use nara_text::Component;
1
1
+
use nara_core::component::Component;
2
2
use serde::Deserialize;
3
3
4
4
-
#[derive(Debug, Deserialize)]
4
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
12
-
#[derive(Debug, Deserialize)]
12
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
21
-
#[derive(Debug, Deserialize)]
21
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
27
-
#[derive(Debug, Deserialize)]
27
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
33
-
#[derive(Debug, Deserialize)]
33
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
40
-
#[derive(Debug, Deserialize)]
40
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
77
-
#[derive(Debug, Deserialize)]
77
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
84
-
#[derive(Debug, Deserialize)]
84
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
92
-
#[derive(Debug, Deserialize)]
92
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
126
-
#[derive(Debug, Deserialize)]
126
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
1
-
pub mod item;
+104
nara_core/src/book.rs
···
1
1
+
use serde::{Deserialize, Serialize};
2
2
+
use sha1::Digest as _;
3
3
+
4
4
+
use crate::component::Component;
5
5
+
6
6
+
pub type BookHash = [u8; 20];
7
7
+
8
8
+
#[derive(Debug, Clone, Serialize, Deserialize)]
9
9
+
pub struct Book {
10
10
+
pub metadata: BookMetadata,
11
11
+
pub content: BookContent,
12
12
+
}
13
13
+
14
14
+
impl Book {
15
15
+
/// Computes the hash of the book's contents.
16
16
+
///
17
17
+
/// Metadata (such as source, generation, or resolved flag) is not taken
18
18
+
/// into account when calculating the hash.
19
19
+
pub fn hash(&self) -> BookHash {
20
20
+
self.content.hash()
21
21
+
}
22
22
+
}
23
23
+
24
24
+
#[derive(Debug, Clone, Serialize, Deserialize)]
25
25
+
pub struct BookMetadata {
26
26
+
pub source: BookSource,
27
27
+
}
28
28
+
29
29
+
#[derive(Debug, Clone, Serialize, Deserialize)]
30
30
+
#[serde(tag = "type", rename_all = "snake_case")]
31
31
+
pub enum BookSource {
32
32
+
/// From an Infinity Item Editor realm file.
33
33
+
InfinityRealm,
34
34
+
/// From a player's save data.
35
35
+
PlayerData {
36
36
+
uuid: String,
37
37
+
inventory: PlayerInventoryKind,
38
38
+
},
39
39
+
/// From a placed block entity, such as a chest.
40
40
+
BlockEntity {
41
41
+
dimension: String,
42
42
+
x: i32,
43
43
+
y: i32,
44
44
+
z: i32,
45
45
+
},
46
46
+
}
47
47
+
48
48
+
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
49
49
+
#[serde(rename_all = "snake_case")]
50
50
+
pub enum PlayerInventoryKind {
51
51
+
Inventory,
52
52
+
EnderChest,
53
53
+
}
54
54
+
55
55
+
#[derive(Debug, Clone, Serialize, Deserialize)]
56
56
+
#[serde(rename_all = "snake_case")]
57
57
+
pub struct BookContent {
58
58
+
pub author: String,
59
59
+
pub pages: Vec<Component>,
60
60
+
pub title: String,
61
61
+
#[serde(default)]
62
62
+
pub generation: BookGeneration,
63
63
+
#[serde(default)]
64
64
+
pub resolved: bool,
65
65
+
}
66
66
+
67
67
+
impl BookContent {
68
68
+
/// Computes the hash of the book's contents.
69
69
+
///
70
70
+
/// Metadata (such as generation or resolved flag) is not taken into account
71
71
+
/// when calculating the hash.
72
72
+
pub fn hash(&self) -> BookHash {
73
73
+
let mut ctx = sha1::Sha1::new();
74
74
+
75
75
+
#[inline(always)]
76
76
+
fn put_str(ctx: &mut sha1::Sha1, s: &str) {
77
77
+
ctx.update(s.as_bytes());
78
78
+
}
79
79
+
80
80
+
#[inline(always)]
81
81
+
fn put_vec_page(ctx: &mut sha1::Sha1, v: &[Component]) {
82
82
+
for c in v {
83
83
+
let s = c.normalize();
84
84
+
put_str(ctx, &s);
85
85
+
}
86
86
+
}
87
87
+
88
88
+
put_str(&mut ctx, &self.author);
89
89
+
put_str(&mut ctx, &self.title);
90
90
+
put_vec_page(&mut ctx, &self.pages);
91
91
+
92
92
+
ctx.finalize().0
93
93
+
}
94
94
+
}
95
95
+
96
96
+
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
97
97
+
#[serde(rename_all = "snake_case")]
98
98
+
pub enum BookGeneration {
99
99
+
#[default]
100
100
+
Original,
101
101
+
CopyOfOriginal,
102
102
+
CopyOfCopy,
103
103
+
Tattered,
104
104
+
}
+4
nara_core/src/lib.rs
···
1
1
+
pub mod book;
2
2
+
pub mod color;
3
3
+
pub mod component;
4
4
+
pub mod profile;
+27
nara_slurper_1_12_core/src/lib.rs
···
1
1
+
use crate::item::{InventoryItemStack, ItemStack, WrittenBookStack};
2
2
+
3
3
+
pub mod item;
4
4
+
5
5
+
pub fn extract_books_from_inventory(
6
6
+
inventory: &[InventoryItemStack],
7
7
+
) -> Vec<WrittenBookStack> {
8
8
+
let mut books: Vec<WrittenBookStack> = Vec::new();
9
9
+
10
10
+
inventory.iter().for_each(|item| match &item.item {
11
11
+
ItemStack::WrittenBook(written_book_stack) => {
12
12
+
books.push(written_book_stack.clone())
13
13
+
}
14
14
+
ItemStack::BlockEntity(block_entity_stack) => {
15
15
+
extract_books_from_inventory(
16
16
+
&block_entity_stack.tag.block_entity.items,
17
17
+
)
18
18
+
.into_iter()
19
19
+
.for_each(|book| {
20
20
+
books.push(book);
21
21
+
});
22
22
+
}
23
23
+
_ => {}
24
24
+
});
25
25
+
26
26
+
books
27
27
+
}
+8
nara_slurper_1_12_infinity/Cargo.toml
···
1
1
+
[package]
2
2
+
name = "nara_slurper_1_12_infinity"
3
3
+
version.workspace = true
4
4
+
edition.workspace = true
5
5
+
6
6
+
[dependencies]
7
7
+
nara_slurper_1_12_core.workspace = true
8
8
+
serde.workspace = true
+5
nara_slurper_1_12_infinity/README.md
···
1
1
+
# nara_slurper_1_12_infinity
2
2
+
3
3
+
Pulls books from an [Infinity Item Editor][iie] vault.
4
4
+
5
5
+
[iie]: https://modrinth.com/mod/infinity-item-editor/version/0.15
+8
nara_slurper_1_12_infinity/src/lib.rs
···
1
1
+
use nara_slurper_1_12_core::item::ItemStack;
2
2
+
use serde::Deserialize;
3
3
+
4
4
+
#[derive(Debug, Deserialize)]
5
5
+
pub struct Realm {
6
6
+
pub realm: Vec<ItemStack>,
7
7
+
pub realm_version: String,
8
8
+
}
+2
-2
nara_slurper_1_12_world/Cargo.toml
···
5
5
6
6
[dependencies]
7
7
crab_nbt.workspace = true
8
8
-
nara_1_12.workspace = true
8
8
+
nara_slurper_1_12_core.workspace = true
9
9
nara_io.workspace = true
10
10
-
nara_text.workspace = true
10
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
1
-
use nara_1_12::item::InventoryItemStack;
1
1
+
use nara_slurper_1_12_core::{
2
2
+
extract_books_from_inventory,
3
3
+
item::{InventoryItemStack, WrittenBookStack},
4
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
20
+
21
21
+
pub fn extract_books_from_playerdata(
22
22
+
player_data: &PlayerData,
23
23
+
) -> Vec<WrittenBookStack> {
24
24
+
let mut books: Vec<WrittenBookStack> = Vec::new();
25
25
+
extract_books_from_inventory(&player_data.inventory)
26
26
+
.into_iter()
27
27
+
.for_each(|book| books.push(book));
28
28
+
extract_books_from_inventory(&player_data.ender_chest)
29
29
+
.into_iter()
30
30
+
.for_each(|book| books.push(book));
31
31
+
books
32
32
+
}
+13
-3
nara_slurper_1_12_world/tests/data.rs
···
1
1
-
use nara_slurper_1_12_world::PlayerData;
1
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
5
+
fn get_playerdata() -> PlayerData {
6
6
+
nara_io::read_nbt(PLAYER_DATA_FIXTURE).unwrap()
7
7
+
}
8
8
+
5
9
#[test]
6
10
fn playerdata() {
7
7
-
let parsed = nara_io::read_nbt::<PlayerData>(PLAYER_DATA_FIXTURE);
8
8
-
insta::assert_debug_snapshot!(parsed);
11
11
+
insta::assert_debug_snapshot!(get_playerdata());
12
12
+
}
13
13
+
14
14
+
#[test]
15
15
+
fn playerdata_extract() {
16
16
+
insta::assert_debug_snapshot!(extract_books_from_playerdata(
17
17
+
&get_playerdata()
18
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
3
-
expression: parsed
3
3
+
expression: get_playerdata()
4
4
---
5
5
-
Ok(
6
6
-
PlayerData {
7
7
-
inventory: [
8
8
-
InventoryItemStack {
9
9
-
item: WrittenBook(
10
10
-
WrittenBookStack {
11
11
-
base: BaseItemStack {
12
12
-
count: 1,
13
13
-
damage: 0,
14
14
-
id: "minecraft:written_book",
15
15
-
},
16
16
-
tag: WrittenBookTag {
17
17
-
base: BaseTag {
18
18
-
display: None,
19
19
-
},
20
20
-
author: "kokiriglade",
21
21
-
title: "just a book",
22
22
-
generation: Original,
23
23
-
resolved: false,
24
24
-
pages: [
25
25
-
String(
26
26
-
"{\"text\":\"Hello, World!\\n\\n§cThis is red text!§r\\n\\n§9This is blue :^)§r§r\"}",
27
27
-
),
28
28
-
],
29
29
-
},
5
5
+
PlayerData {
6
6
+
inventory: [
7
7
+
InventoryItemStack {
8
8
+
item: WrittenBook(
9
9
+
WrittenBookStack {
10
10
+
base: BaseItemStack {
11
11
+
count: 1,
12
12
+
damage: 0,
13
13
+
id: "minecraft:written_book",
30
14
},
31
31
-
),
32
32
-
slot: 0,
33
33
-
},
34
34
-
InventoryItemStack {
35
35
-
item: BlockEntity(
36
36
-
BlockEntityStack {
37
37
-
base: BaseItemStack {
38
38
-
count: 1,
39
39
-
damage: 0,
40
40
-
id: "minecraft:pink_shulker_box",
15
15
+
tag: WrittenBookTag {
16
16
+
base: BaseTag {
17
17
+
display: None,
41
18
},
42
42
-
tag: BlockEntityTag {
43
43
-
base: BaseTag {
44
44
-
display: None,
45
45
-
},
46
46
-
block_entity: BlockEntity {
47
47
-
items: [
48
48
-
InventoryItemStack {
49
49
-
item: WrittenBook(
50
50
-
WrittenBookStack {
51
51
-
base: BaseItemStack {
52
52
-
count: 1,
53
53
-
damage: 0,
54
54
-
id: "minecraft:written_book",
19
19
+
author: "kokiriglade",
20
20
+
title: "just a book",
21
21
+
generation: Original,
22
22
+
resolved: false,
23
23
+
pages: [
24
24
+
Array(
25
25
+
[
26
26
+
Object(
27
27
+
ComponentObject {
28
28
+
text: Some(
29
29
+
TextComponent {
30
30
+
text: "Hello, World!\n\n",
31
31
+
},
32
32
+
),
33
33
+
translation: None,
34
34
+
score: None,
35
35
+
selector: None,
36
36
+
keybind: None,
37
37
+
nbt: None,
38
38
+
object: None,
39
39
+
formatting: ComponentFormatting {
40
40
+
color: None,
41
41
+
font: None,
42
42
+
bold: None,
43
43
+
italic: None,
44
44
+
underlined: None,
45
45
+
strikethrough: None,
46
46
+
obfuscated: None,
47
47
+
},
48
48
+
children: [],
49
49
+
},
50
50
+
),
51
51
+
Object(
52
52
+
ComponentObject {
53
53
+
text: Some(
54
54
+
TextComponent {
55
55
+
text: "This is red text!",
55
56
},
56
56
-
tag: WrittenBookTag {
57
57
-
base: BaseTag {
58
58
-
display: None,
59
59
-
},
60
60
-
author: "kokiriglade",
61
61
-
title: "in a shulker box",
62
62
-
generation: Original,
63
63
-
resolved: false,
64
64
-
pages: [
65
65
-
String(
66
66
-
"{\"text\":\"this one is in a shulker box!! wow!!!\"}",
67
67
-
),
68
68
-
],
57
57
+
),
58
58
+
translation: None,
59
59
+
score: None,
60
60
+
selector: None,
61
61
+
keybind: None,
62
62
+
nbt: None,
63
63
+
object: None,
64
64
+
formatting: ComponentFormatting {
65
65
+
color: Some(
66
66
+
Named(
67
67
+
Red,
68
68
+
),
69
69
+
),
70
70
+
font: None,
71
71
+
bold: None,
72
72
+
italic: None,
73
73
+
underlined: None,
74
74
+
strikethrough: None,
75
75
+
obfuscated: None,
76
76
+
},
77
77
+
children: [],
78
78
+
},
79
79
+
),
80
80
+
Object(
81
81
+
ComponentObject {
82
82
+
text: Some(
83
83
+
TextComponent {
84
84
+
text: "\n\n",
69
85
},
86
86
+
),
87
87
+
translation: None,
88
88
+
score: None,
89
89
+
selector: None,
90
90
+
keybind: None,
91
91
+
nbt: None,
92
92
+
object: None,
93
93
+
formatting: ComponentFormatting {
94
94
+
color: None,
95
95
+
font: None,
96
96
+
bold: None,
97
97
+
italic: None,
98
98
+
underlined: None,
99
99
+
strikethrough: None,
100
100
+
obfuscated: None,
70
101
},
71
71
-
),
72
72
-
slot: 2,
73
73
-
},
102
102
+
children: [],
103
103
+
},
104
104
+
),
105
105
+
Object(
106
106
+
ComponentObject {
107
107
+
text: Some(
108
108
+
TextComponent {
109
109
+
text: "This is blue :^)",
110
110
+
},
111
111
+
),
112
112
+
translation: None,
113
113
+
score: None,
114
114
+
selector: None,
115
115
+
keybind: None,
116
116
+
nbt: None,
117
117
+
object: None,
118
118
+
formatting: ComponentFormatting {
119
119
+
color: Some(
120
120
+
Named(
121
121
+
Blue,
122
122
+
),
123
123
+
),
124
124
+
font: None,
125
125
+
bold: None,
126
126
+
italic: None,
127
127
+
underlined: None,
128
128
+
strikethrough: None,
129
129
+
obfuscated: None,
130
130
+
},
131
131
+
children: [],
132
132
+
},
133
133
+
),
74
134
],
75
75
-
},
76
76
-
},
77
77
-
},
78
78
-
),
79
79
-
slot: 1,
80
80
-
},
81
81
-
InventoryItemStack {
82
82
-
item: Base(
83
83
-
BaseItemStack {
84
84
-
count: 1,
85
85
-
damage: 0,
86
86
-
id: "minecraft:stone",
135
135
+
),
136
136
+
],
87
137
},
88
88
-
),
89
89
-
slot: 2,
90
90
-
},
91
91
-
InventoryItemStack {
92
92
-
item: Base(
93
93
-
BaseItemStack {
138
138
+
},
139
139
+
),
140
140
+
slot: 0,
141
141
+
},
142
142
+
InventoryItemStack {
143
143
+
item: BlockEntity(
144
144
+
BlockEntityStack {
145
145
+
base: BaseItemStack {
94
146
count: 1,
95
147
damage: 0,
96
96
-
id: "minecraft:reeds",
148
148
+
id: "minecraft:pink_shulker_box",
97
149
},
98
98
-
),
99
99
-
slot: 4,
100
100
-
},
101
101
-
InventoryItemStack {
102
102
-
item: Base(
103
103
-
BaseItemStack {
104
104
-
count: 1,
105
105
-
damage: 0,
106
106
-
id: "minecraft:golden_chestplate",
150
150
+
tag: BlockEntityTag {
151
151
+
base: BaseTag {
152
152
+
display: None,
153
153
+
},
154
154
+
block_entity: BlockEntity {
155
155
+
items: [
156
156
+
InventoryItemStack {
157
157
+
item: WrittenBook(
158
158
+
WrittenBookStack {
159
159
+
base: BaseItemStack {
160
160
+
count: 1,
161
161
+
damage: 0,
162
162
+
id: "minecraft:written_book",
163
163
+
},
164
164
+
tag: WrittenBookTag {
165
165
+
base: BaseTag {
166
166
+
display: None,
167
167
+
},
168
168
+
author: "kokiriglade",
169
169
+
title: "in a shulker box",
170
170
+
generation: Original,
171
171
+
resolved: false,
172
172
+
pages: [
173
173
+
Object(
174
174
+
ComponentObject {
175
175
+
text: Some(
176
176
+
TextComponent {
177
177
+
text: "this one is in a shulker box!! wow!!!",
178
178
+
},
179
179
+
),
180
180
+
translation: None,
181
181
+
score: None,
182
182
+
selector: None,
183
183
+
keybind: None,
184
184
+
nbt: None,
185
185
+
object: None,
186
186
+
formatting: ComponentFormatting {
187
187
+
color: None,
188
188
+
font: None,
189
189
+
bold: None,
190
190
+
italic: None,
191
191
+
underlined: None,
192
192
+
strikethrough: None,
193
193
+
obfuscated: None,
194
194
+
},
195
195
+
children: [],
196
196
+
},
197
197
+
),
198
198
+
],
199
199
+
},
200
200
+
},
201
201
+
),
202
202
+
slot: 2,
203
203
+
},
204
204
+
],
205
205
+
},
107
206
},
108
108
-
),
109
109
-
slot: 5,
110
110
-
},
111
111
-
],
112
112
-
ender_chest: [],
113
113
-
},
114
114
-
)
207
207
+
},
208
208
+
),
209
209
+
slot: 1,
210
210
+
},
211
211
+
InventoryItemStack {
212
212
+
item: Base(
213
213
+
BaseItemStack {
214
214
+
count: 1,
215
215
+
damage: 0,
216
216
+
id: "minecraft:stone",
217
217
+
},
218
218
+
),
219
219
+
slot: 2,
220
220
+
},
221
221
+
InventoryItemStack {
222
222
+
item: Base(
223
223
+
BaseItemStack {
224
224
+
count: 1,
225
225
+
damage: 0,
226
226
+
id: "minecraft:reeds",
227
227
+
},
228
228
+
),
229
229
+
slot: 4,
230
230
+
},
231
231
+
InventoryItemStack {
232
232
+
item: Base(
233
233
+
BaseItemStack {
234
234
+
count: 1,
235
235
+
damage: 0,
236
236
+
id: "minecraft:golden_chestplate",
237
237
+
},
238
238
+
),
239
239
+
slot: 5,
240
240
+
},
241
241
+
],
242
242
+
ender_chest: [],
243
243
+
}
+179
nara_slurper_1_12_world/tests/snapshots/data__playerdata_extract.snap
···
1
1
+
---
2
2
+
source: nara_slurper_1_12_world/tests/data.rs
3
3
+
expression: extract_books_from_playerdata(&get_playerdata())
4
4
+
---
5
5
+
[
6
6
+
WrittenBookStack {
7
7
+
base: BaseItemStack {
8
8
+
count: 1,
9
9
+
damage: 0,
10
10
+
id: "minecraft:written_book",
11
11
+
},
12
12
+
tag: WrittenBookTag {
13
13
+
base: BaseTag {
14
14
+
display: None,
15
15
+
},
16
16
+
author: "kokiriglade",
17
17
+
title: "just a book",
18
18
+
generation: Original,
19
19
+
resolved: false,
20
20
+
pages: [
21
21
+
Array(
22
22
+
[
23
23
+
Object(
24
24
+
ComponentObject {
25
25
+
text: Some(
26
26
+
TextComponent {
27
27
+
text: "Hello, World!\n\n",
28
28
+
},
29
29
+
),
30
30
+
translation: None,
31
31
+
score: None,
32
32
+
selector: None,
33
33
+
keybind: None,
34
34
+
nbt: None,
35
35
+
object: None,
36
36
+
formatting: ComponentFormatting {
37
37
+
color: None,
38
38
+
font: None,
39
39
+
bold: None,
40
40
+
italic: None,
41
41
+
underlined: None,
42
42
+
strikethrough: None,
43
43
+
obfuscated: None,
44
44
+
},
45
45
+
children: [],
46
46
+
},
47
47
+
),
48
48
+
Object(
49
49
+
ComponentObject {
50
50
+
text: Some(
51
51
+
TextComponent {
52
52
+
text: "This is red text!",
53
53
+
},
54
54
+
),
55
55
+
translation: None,
56
56
+
score: None,
57
57
+
selector: None,
58
58
+
keybind: None,
59
59
+
nbt: None,
60
60
+
object: None,
61
61
+
formatting: ComponentFormatting {
62
62
+
color: Some(
63
63
+
Named(
64
64
+
Red,
65
65
+
),
66
66
+
),
67
67
+
font: None,
68
68
+
bold: None,
69
69
+
italic: None,
70
70
+
underlined: None,
71
71
+
strikethrough: None,
72
72
+
obfuscated: None,
73
73
+
},
74
74
+
children: [],
75
75
+
},
76
76
+
),
77
77
+
Object(
78
78
+
ComponentObject {
79
79
+
text: Some(
80
80
+
TextComponent {
81
81
+
text: "\n\n",
82
82
+
},
83
83
+
),
84
84
+
translation: None,
85
85
+
score: None,
86
86
+
selector: None,
87
87
+
keybind: None,
88
88
+
nbt: None,
89
89
+
object: None,
90
90
+
formatting: ComponentFormatting {
91
91
+
color: None,
92
92
+
font: None,
93
93
+
bold: None,
94
94
+
italic: None,
95
95
+
underlined: None,
96
96
+
strikethrough: None,
97
97
+
obfuscated: None,
98
98
+
},
99
99
+
children: [],
100
100
+
},
101
101
+
),
102
102
+
Object(
103
103
+
ComponentObject {
104
104
+
text: Some(
105
105
+
TextComponent {
106
106
+
text: "This is blue :^)",
107
107
+
},
108
108
+
),
109
109
+
translation: None,
110
110
+
score: None,
111
111
+
selector: None,
112
112
+
keybind: None,
113
113
+
nbt: None,
114
114
+
object: None,
115
115
+
formatting: ComponentFormatting {
116
116
+
color: Some(
117
117
+
Named(
118
118
+
Blue,
119
119
+
),
120
120
+
),
121
121
+
font: None,
122
122
+
bold: None,
123
123
+
italic: None,
124
124
+
underlined: None,
125
125
+
strikethrough: None,
126
126
+
obfuscated: None,
127
127
+
},
128
128
+
children: [],
129
129
+
},
130
130
+
),
131
131
+
],
132
132
+
),
133
133
+
],
134
134
+
},
135
135
+
},
136
136
+
WrittenBookStack {
137
137
+
base: BaseItemStack {
138
138
+
count: 1,
139
139
+
damage: 0,
140
140
+
id: "minecraft:written_book",
141
141
+
},
142
142
+
tag: WrittenBookTag {
143
143
+
base: BaseTag {
144
144
+
display: None,
145
145
+
},
146
146
+
author: "kokiriglade",
147
147
+
title: "in a shulker box",
148
148
+
generation: Original,
149
149
+
resolved: false,
150
150
+
pages: [
151
151
+
Object(
152
152
+
ComponentObject {
153
153
+
text: Some(
154
154
+
TextComponent {
155
155
+
text: "this one is in a shulker box!! wow!!!",
156
156
+
},
157
157
+
),
158
158
+
translation: None,
159
159
+
score: None,
160
160
+
selector: None,
161
161
+
keybind: None,
162
162
+
nbt: None,
163
163
+
object: None,
164
164
+
formatting: ComponentFormatting {
165
165
+
color: None,
166
166
+
font: None,
167
167
+
bold: None,
168
168
+
italic: None,
169
169
+
underlined: None,
170
170
+
strikethrough: None,
171
171
+
obfuscated: None,
172
172
+
},
173
173
+
children: [],
174
174
+
},
175
175
+
),
176
176
+
],
177
177
+
},
178
178
+
},
179
179
+
]
+3
-1
nara_text/Cargo.toml
nara_core/Cargo.toml
···
1
1
[package]
2
2
-
name = "nara_text"
2
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
8
+
serde_json.workspace = true
8
9
serde_with.workspace = true
10
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
1
-
# nara_text
1
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
1
-
use serde::{Deserialize, Serialize};
2
2
-
3
3
-
pub mod color;
4
4
-
pub mod profile;
5
5
-
6
6
-
pub const LEGACY_SYMBOL: char = '§';
7
7
-
8
8
-
#[derive(Debug, Serialize, Deserialize)]
9
9
-
#[serde(untagged)]
10
10
-
pub enum Component {
11
11
-
String(String),
12
12
-
Object(ComponentObject),
13
13
-
Array(Vec<Component>),
14
14
-
}
15
15
-
16
16
-
#[serde_with::skip_serializing_none]
17
17
-
#[derive(Debug, Serialize, Deserialize)]
18
18
-
pub struct ComponentObject {
19
19
-
#[serde(flatten)]
20
20
-
text: Option<TextComponent>,
21
21
-
#[serde(flatten)]
22
22
-
translation: Option<TranslationComponent>,
23
23
-
// DO NOT FLATTEN THE SCORE COMPONENT
24
24
-
score: Option<ScoreComponent>,
25
25
-
#[serde(flatten)]
26
26
-
selector: Option<SelectorComponent>,
27
27
-
#[serde(flatten)]
28
28
-
keybind: Option<KeybindComponent>,
29
29
-
#[serde(flatten)]
30
30
-
nbt: Option<NbtComponent>,
31
31
-
#[serde(flatten)]
32
32
-
object: Option<ObjectComponent>,
33
33
-
#[serde(flatten)]
34
34
-
formatting: ComponentFormatting,
35
35
-
#[serde(default, rename = "extra", skip_serializing_if = "Vec::is_empty")]
36
36
-
children: Vec<Component>,
37
37
-
}
38
38
-
39
39
-
// TODO(kokiriglade): make this a struct later
40
40
-
pub type Identifier = String;
41
41
-
42
42
-
#[serde_with::skip_serializing_none]
43
43
-
#[derive(Debug, Serialize, Deserialize)]
44
44
-
pub struct ComponentFormatting {
45
45
-
color: Option<color::Color>,
46
46
-
font: Option<Identifier>,
47
47
-
bold: Option<bool>,
48
48
-
italic: Option<bool>,
49
49
-
underlined: Option<bool>,
50
50
-
strikethrough: Option<bool>,
51
51
-
obfuscated: Option<bool>,
52
52
-
}
53
53
-
54
54
-
#[derive(Debug, Serialize, Deserialize)]
55
55
-
pub struct TextComponent {
56
56
-
text: String,
57
57
-
}
58
58
-
59
59
-
#[serde_with::skip_serializing_none]
60
60
-
#[derive(Debug, Serialize, Deserialize)]
61
61
-
pub struct TranslationComponent {
62
62
-
translate: String,
63
63
-
fallback: Option<String>,
64
64
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
65
65
-
with: Vec<Component>,
66
66
-
}
67
67
-
68
68
-
#[serde_with::skip_serializing_none]
69
69
-
#[derive(Debug, Serialize, Deserialize)]
70
70
-
pub struct ScoreComponent {
71
71
-
name: String,
72
72
-
objective: String,
73
73
-
}
74
74
-
75
75
-
#[serde_with::skip_serializing_none]
76
76
-
#[derive(Debug, Serialize, Deserialize)]
77
77
-
pub struct SelectorComponent {
78
78
-
selector: String,
79
79
-
separator: Option<Box<Component>>,
80
80
-
}
81
81
-
82
82
-
#[serde_with::skip_serializing_none]
83
83
-
#[derive(Debug, Serialize, Deserialize)]
84
84
-
pub struct KeybindComponent {
85
85
-
keybind: String,
86
86
-
}
87
87
-
88
88
-
#[derive(Debug, Serialize, Deserialize)]
89
89
-
#[serde(rename_all = "snake_case")]
90
90
-
pub enum NbtComponentSource {
91
91
-
Block,
92
92
-
Entity,
93
93
-
Storage,
94
94
-
}
95
95
-
96
96
-
#[serde_with::skip_serializing_none]
97
97
-
#[derive(Debug, Serialize, Deserialize)]
98
98
-
pub struct NbtComponent {
99
99
-
source: Option<NbtComponentSource>,
100
100
-
#[serde(rename = "nbt")]
101
101
-
path: String,
102
102
-
interpret: Option<bool>,
103
103
-
separator: Option<Box<Component>>,
104
104
-
entity: Option<String>,
105
105
-
block: Option<String>,
106
106
-
storage: Option<Identifier>,
107
107
-
}
108
108
-
109
109
-
#[derive(Debug, Serialize, Deserialize)]
110
110
-
#[serde(tag = "object", rename_all = "snake_case")]
111
111
-
pub enum ObjectComponent {
112
112
-
Atlas(AtlasObject),
113
113
-
Player(PlayerObject),
114
114
-
}
115
115
-
116
116
-
#[serde_with::skip_serializing_none]
117
117
-
#[derive(Debug, Serialize, Deserialize)]
118
118
-
pub struct AtlasObject {
119
119
-
atlas: Option<Identifier>,
120
120
-
sprite: Identifier,
121
121
-
}
122
122
-
123
123
-
#[derive(Debug, Serialize, Deserialize)]
124
124
-
#[serde(untagged)]
125
125
-
pub enum PlayerProfileOrName {
126
126
-
Profile(profile::PlayerProfile),
127
127
-
Name(String),
128
128
-
}
129
129
-
130
130
-
#[serde_with::skip_serializing_none]
131
131
-
#[derive(Debug, Serialize, Deserialize)]
132
132
-
pub struct PlayerObject {
133
133
-
player: PlayerProfileOrName,
134
134
-
hat: Option<bool>,
135
135
-
}
+6
-8
nara_text/src/profile.rs
nara_core/src/profile.rs
···
1
1
use serde::{Deserialize, Serialize};
2
2
3
3
-
use crate::Identifier;
4
4
-
5
3
#[serde_with::skip_serializing_none]
6
6
-
#[derive(Debug, Serialize, Deserialize)]
4
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
12
-
texture: Option<Identifier>,
13
13
-
cape: Option<Identifier>,
14
14
-
elytra: Option<Identifier>,
10
10
+
texture: Option<String>,
11
11
+
cape: Option<String>,
12
12
+
elytra: Option<String>,
15
13
model: Option<PlayerModel>,
16
14
}
17
15
18
16
#[serde_with::skip_serializing_none]
19
19
-
#[derive(Debug, Serialize, Deserialize)]
17
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
26
-
#[derive(Debug, Serialize, Deserialize)]
24
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
1
-
use nara_text::Component;
1
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
1
-
use nara_text::Component;
1
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
1
-
use nara_text::Component;
1
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
1
-
use nara_text::Component;
1
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
1
-
use nara_text::Component;
1
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
2
-
source: nara_text/tests/keybind.rs
2
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
2
-
source: nara_text/tests/nbt.rs
2
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
2
-
source: nara_text/tests/nbt.rs
2
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
2
-
source: nara_text/tests/nbt.rs
2
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
2
-
source: nara_text/tests/object.rs
2
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
2
-
source: nara_text/tests/object.rs
2
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
2
-
source: nara_text/tests/object.rs
2
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
2
-
source: nara_text/tests/score.rs
2
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
2
-
source: nara_text/tests/selector.rs
2
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
2
-
source: nara_text/tests/text.rs
2
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
2
-
source: nara_text/tests/text.rs
2
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
2
-
source: nara_text/tests/translation.rs
2
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
1
-
use nara_text::Component;
1
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
1
-
use nara_text::Component;
1
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
1
-
use std::time::{SystemTime, SystemTimeError};
2
2
-
3
1
use ahash::{AHashMap, AHashSet};
4
2
use lru::LruCache;
3
3
+
use nara_core::{
4
4
+
book::{Book, BookHash, BookSource},
5
5
+
component::Component,
6
6
+
};
5
7
use smol_str::SmolStr;
6
8
use std::{cell::RefCell, num::NonZeroUsize};
7
9
use strsim::jaro_winkler;
8
10
9
9
-
use serde::{Deserialize, Serialize};
10
10
-
11
11
-
/// Item model types parsed from realm data.
12
12
-
pub mod item;
13
13
-
pub mod text;
14
14
-
15
15
-
use item::{ItemStack, WrittenBookTag};
16
16
-
use text::{Component, normalize_page, scrub_component, scrub_unwanted_glyphs};
17
17
-
18
18
-
pub type BookHash = [u8; 20];
19
11
pub type BookId = usize;
20
12
21
21
-
/// Raw realm export data (top-level list of items).
22
22
-
#[derive(Debug, Serialize, Deserialize)]
23
23
-
pub struct Realm {
24
24
-
pub realm: Vec<ItemStack>,
25
25
-
pub realm_version: String,
26
26
-
}
27
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
31
-
books: Vec<WrittenBookTag>,
16
16
+
books: Vec<Book>,
32
17
33
18
by_hash: AHashMap<BookHash, BookId>,
34
34
-
location_by_hash: AHashMap<BookHash, SmolStr>,
19
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
70
-
#[derive(Debug, thiserror::Error)]
71
71
-
pub enum LibraryError {
72
72
-
#[error("Book has no location: {0:?}")]
73
73
-
MissingLocation(WrittenBookTag),
74
74
-
#[error("Unsupported realm version '{0}'")]
75
75
-
UnsupportedRealmVersion(String),
76
76
-
#[error(transparent)]
77
77
-
SystemTimeError(#[from] SystemTimeError),
78
78
-
}
79
79
-
80
80
-
pub type Result<T> = std::result::Result<T, LibraryError>;
81
81
-
82
55
impl Library {
83
83
-
/// Builds a library index from a realm export.
84
84
-
pub fn new(
85
85
-
realm: Realm,
86
86
-
content_threshold: f64,
87
87
-
author_threshold: f64,
88
88
-
title_threshold: f64,
89
89
-
warn_duplicates: bool,
90
90
-
warn_empty: bool,
91
91
-
filter_empty_books: bool,
92
92
-
) -> Result<Library> {
93
93
-
if realm.realm_version != "0.2" {
94
94
-
return Err(LibraryError::UnsupportedRealmVersion(
95
95
-
realm.realm_version,
96
96
-
));
97
97
-
}
98
98
-
99
99
-
let start = SystemTime::now();
100
100
-
let mut library = Library {
101
101
-
books: Vec::new(),
102
102
-
by_hash: AHashMap::new(),
103
103
-
location_by_hash: AHashMap::new(),
104
104
-
by_category: AHashMap::new(),
105
105
-
by_author_lc: AHashMap::new(),
106
106
-
norm_title: Vec::new(),
107
107
-
norm_author: Vec::new(),
108
108
-
norm_contents: Vec::new(),
109
109
-
tri_title: AHashMap::new(),
110
110
-
tri_author: AHashMap::new(),
111
111
-
tri_contents: AHashMap::new(),
112
112
-
content_threshold,
113
113
-
author_threshold,
114
114
-
title_threshold,
115
115
-
cache_books_by_author: RefCell::new(new_lru(CACHE_BY_AUTHOR_CAP)),
116
116
-
cache_books_in_category: RefCell::new(new_lru(
117
117
-
CACHE_BY_CATEGORY_CAP,
118
118
-
)),
119
119
-
cache_fuzzy_title: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
120
120
-
cache_fuzzy_author: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
121
121
-
cache_fuzzy_contents: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
122
122
-
cache_fuzzy_all: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
123
123
-
duplicate_books_filtered: 0,
124
124
-
empty_books_filtered: 0,
125
125
-
};
126
126
-
127
127
-
fn traverse_items(
128
128
-
items: Vec<ItemStack>,
129
129
-
current_location: Option<SmolStr>,
130
130
-
library: &mut Library,
131
131
-
warn_duplicates: bool,
132
132
-
warn_empty: bool,
133
133
-
filter_empty_books: bool,
134
134
-
) -> Result<()> {
135
135
-
for item in items {
136
136
-
match item {
137
137
-
ItemStack::WrittenBook { tag, .. } => {
138
138
-
library.add_book(
139
139
-
tag,
140
140
-
current_location.clone(),
141
141
-
warn_duplicates,
142
142
-
warn_empty,
143
143
-
filter_empty_books,
144
144
-
)?;
145
145
-
}
146
146
-
ItemStack::ShulkerBox { tag, .. } => {
147
147
-
let shulker_location = tag
148
148
-
.display
149
149
-
.as_ref()
150
150
-
.and_then(|d| d.name.as_deref())
151
151
-
.map(SmolStr::new);
152
152
-
153
153
-
let next_location = shulker_location
154
154
-
.or_else(|| current_location.clone());
155
155
-
traverse_items(
156
156
-
tag.block_entity.items,
157
157
-
next_location,
158
158
-
library,
159
159
-
warn_duplicates,
160
160
-
warn_empty,
161
161
-
filter_empty_books,
162
162
-
)?;
163
163
-
}
164
164
-
}
165
165
-
}
166
166
-
Ok(())
167
167
-
}
168
168
-
169
169
-
traverse_items(
170
170
-
realm.realm,
171
171
-
None,
172
172
-
&mut library,
173
173
-
warn_duplicates,
174
174
-
warn_empty,
175
175
-
filter_empty_books,
176
176
-
)?;
177
177
-
178
178
-
let elapsed_ms = SystemTime::now().duration_since(start)?.as_nanos()
179
179
-
as f64
180
180
-
/ 1_000_000.0;
181
181
-
tracing::info!(
182
182
-
"Indexed {0} books in {1} categories in {2}ms (filtered {3} duplicates, {4} empty)",
183
183
-
library.books.len(),
184
184
-
library.by_category.keys().len(),
185
185
-
elapsed_ms,
186
186
-
library.duplicate_books_filtered,
187
187
-
library.empty_books_filtered,
188
188
-
);
189
189
-
Ok(library)
190
190
-
}
191
191
-
192
56
/// Inserts a book and updates all indices and caches.
193
57
fn add_book(
194
58
&mut self,
195
195
-
mut book: WrittenBookTag,
196
196
-
location: Option<SmolStr>,
59
59
+
book: Book,
197
60
warn_duplicates: bool,
198
61
warn_empty: bool,
199
62
filter_empty_books: bool,
200
200
-
) -> Result<()> {
201
201
-
book.title = scrub_unwanted_glyphs(&book.title);
202
202
-
book.author = scrub_unwanted_glyphs(&book.author);
203
203
-
for page in &mut book.pages {
204
204
-
scrub_component(page);
205
205
-
}
63
63
+
) {
206
64
if filter_empty_books && book_plain_text_empty(&book) {
207
65
if warn_empty {
208
66
tracing::warn!(
209
209
-
"Skipping empty book in location {0:?}: {1} by {2}",
210
210
-
location,
211
211
-
book.title,
212
212
-
book.author
67
67
+
"Skipping empty book with source {0:?}: {1} by {2}",
68
68
+
book.metadata.source,
69
69
+
book.content.title,
70
70
+
book.content.author
213
71
);
214
72
}
215
73
self.empty_books_filtered += 1;
216
216
-
return Ok(());
217
217
-
}
218
218
-
219
219
-
let Some(location) = location else {
220
220
-
return Err(LibraryError::MissingLocation(book));
221
221
-
};
222
222
-
223
223
-
let Some(category) = category_from_location(&location) else {
224
224
-
return Err(LibraryError::MissingLocation(book));
74
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
230
-
let existing_book_location =
231
231
-
self.location_by_hash.get(&h).expect("book to exist");
80
80
+
let existing_book_source =
81
81
+
self.source_by_hash.get(&h).expect("book to exist");
232
82
tracing::warn!(
233
233
-
"Duplicate book in location {0:?}: {1} by {2} [already in {3}]",
234
234
-
location,
235
235
-
book.title,
236
236
-
book.author,
237
237
-
existing_book_location
83
83
+
"Duplicate book with source {0:?}: {1} by {2} [already one with {3:?}]",
84
84
+
book.metadata.source,
85
85
+
book.content.title,
86
86
+
book.content.author,
87
87
+
existing_book_source
238
88
);
239
89
}
240
90
self.duplicate_books_filtered += 1;
241
241
-
return Ok(());
91
91
+
return;
242
92
}
243
93
244
94
let id = self.books.len();
95
95
+
96
96
+
let source = book.metadata.source.clone();
245
97
self.books.push(book);
246
98
99
99
+
let category: SmolStr = "todo".into(); // TODO(kokiriglade): classify from contents
100
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
253
-
self.location_by_hash.insert(h, location);
107
107
+
self.source_by_hash.insert(h, source);
254
108
255
255
-
let author_lc = SmolStr::new(normalize(&self.books[id].author));
109
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
261
-
self.norm_title.push(normalize(&self.books[id].title));
262
262
-
self.norm_author.push(normalize(&self.books[id].author));
115
115
+
self.norm_title
116
116
+
.push(normalize(&self.books[id].content.title));
117
117
+
self.norm_author
118
118
+
.push(normalize(&self.books[id].content.author));
263
119
self.norm_contents
264
264
-
.push(normalize_contents(&self.books[id].pages));
120
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
277
-
278
278
-
Ok(())
279
133
}
280
134
281
135
/// Looks up a book by its content hash.
282
136
#[inline]
283
283
-
pub fn book_by_hash(&self, hash: BookHash) -> Option<&WrittenBookTag> {
137
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
292
-
) -> impl Iterator<Item = &'a WrittenBookTag> + 'a {
146
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
316
-
) -> impl Iterator<Item = &'a WrittenBookTag> + 'a {
170
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
336
-
pub fn fuzzy_title(
337
337
-
&self,
338
338
-
query: &str,
339
339
-
limit: usize,
340
340
-
) -> Vec<(&WrittenBookTag, f64)> {
190
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
376
-
pub fn fuzzy_author(
377
377
-
&self,
378
378
-
query: &str,
379
379
-
limit: usize,
380
380
-
) -> Vec<(&WrittenBookTag, f64)> {
226
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
423
-
) -> Vec<(&WrittenBookTag, f64)> {
269
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
462
-
pub fn fuzzy(
463
463
-
&self,
464
464
-
query: &str,
465
465
-
limit: usize,
466
466
-
) -> Vec<(&WrittenBookTag, f64)> {
308
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
543
-
pub fn all_books<'a>(
544
544
-
&'a self,
545
545
-
) -> impl Iterator<Item = &'a WrittenBookTag> + 'a {
385
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
558
-
/// Returns the location string for a book hash, if present.
398
398
+
/// Returns the source for a book hash, if present.
559
399
#[inline]
560
560
-
pub fn location_for_hash(&self, hash: &BookHash) -> Option<&SmolStr> {
561
561
-
self.location_by_hash.get(hash)
400
400
+
pub fn source_for_hash(&self, hash: &BookHash) -> Option<&BookSource> {
401
401
+
self.source_by_hash.get(hash)
562
402
}
563
403
}
564
404
···
568
408
s.to_lowercase()
569
409
}
570
410
571
571
-
fn book_plain_text_empty(book: &WrittenBookTag) -> bool {
572
572
-
book.pages.is_empty()
411
411
+
fn book_plain_text_empty(book: &Book) -> bool {
412
412
+
book.content.pages.is_empty()
573
413
|| book
414
414
+
.content
574
415
.pages
575
416
.iter()
576
576
-
.all(|page| normalize_page(page).trim().is_empty())
417
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
586
-
}
587
587
-
588
588
-
/// Extracts category prefix from a location string.
589
589
-
pub(crate) fn category_from_location(location: &str) -> Option<SmolStr> {
590
590
-
let first_digit = location.find(|c: char| c.is_ascii_digit())?;
591
591
-
let category = location[..first_digit].trim_end();
592
592
-
if category.is_empty() {
593
593
-
None
594
594
-
} else {
595
595
-
Some(SmolStr::new(category))
596
596
-
}
597
427
}
598
428
599
429
const MAX_CONTENT_INDEX_CHARS: usize = 16_384;
-131
src/library/item.rs
···
1
1
-
use serde::{Deserialize, Serialize};
2
2
-
use serde_with::skip_serializing_none;
3
3
-
use sha1::Digest;
4
4
-
5
5
-
use crate::library::{
6
6
-
BookHash,
7
7
-
text::{Component, normalize_page},
8
8
-
};
9
9
-
10
10
-
/// Top-level item container parsed from realm exports.
11
11
-
#[skip_serializing_none]
12
12
-
#[derive(Debug, Clone, Serialize, Deserialize)]
13
13
-
#[serde(tag = "id", rename_all_fields = "PascalCase")]
14
14
-
pub enum ItemStack {
15
15
-
/// A written book stack with the data we index.
16
16
-
#[serde(rename = "minecraft:written_book")]
17
17
-
WrittenBook {
18
18
-
count: i8,
19
19
-
damage: i16,
20
20
-
slot: Option<i8>,
21
21
-
#[serde(rename = "tag")]
22
22
-
tag: WrittenBookTag,
23
23
-
},
24
24
-
/// A shulker box that can contain nested items.
25
25
-
#[serde(
26
26
-
rename = "minecraft:red_shulker_box",
27
27
-
alias = "minecraft:orange_shulker_box",
28
28
-
alias = "minecraft:yellow_shulker_box",
29
29
-
alias = "minecraft:lime_shulker_box",
30
30
-
alias = "minecraft:green_shulker_box",
31
31
-
alias = "minecraft:cyan_shulker_box",
32
32
-
alias = "minecraft:light_blue_shulker_box",
33
33
-
alias = "minecraft:blue_shulker_box",
34
34
-
alias = "minecraft:purple_shulker_box",
35
35
-
alias = "minecraft:magenta_shulker_box",
36
36
-
alias = "minecraft:pink_shulker_box",
37
37
-
alias = "minecraft:brown_shulker_box",
38
38
-
alias = "minecraft:black_shulker_box",
39
39
-
alias = "minecraft:gray_shulker_box",
40
40
-
alias = "minecraft:light_gray_shulker_box",
41
41
-
alias = "minecraft:white_shulker_box",
42
42
-
alias = "minecraft:shulker_box"
43
43
-
)]
44
44
-
ShulkerBox {
45
45
-
count: i8,
46
46
-
damage: i16,
47
47
-
slot: Option<i8>,
48
48
-
#[serde(rename = "tag")]
49
49
-
tag: ShulkerBoxTag,
50
50
-
},
51
51
-
}
52
52
-
53
53
-
/// NBT tag data for a written book.
54
54
-
#[skip_serializing_none]
55
55
-
#[derive(Debug, Clone, Serialize, Deserialize)]
56
56
-
pub struct WrittenBookTag {
57
57
-
pub display: Option<DisplayTag>,
58
58
-
59
59
-
#[serde(rename = "author")]
60
60
-
pub author: String,
61
61
-
62
62
-
#[serde(rename = "title")]
63
63
-
pub title: String,
64
64
-
65
65
-
#[serde(rename = "generation")]
66
66
-
pub generation: Option<i32>,
67
67
-
68
68
-
#[serde(rename = "resolved")]
69
69
-
pub resolved: Option<i8>,
70
70
-
71
71
-
#[serde(rename = "pages", default)]
72
72
-
pub pages: Vec<Component>,
73
73
-
}
74
74
-
75
75
-
impl WrittenBookTag {
76
76
-
/// Computes the hash of the book's contents.
77
77
-
///
78
78
-
/// Metadata is not taken into account when calculating the hash. The hash should _only_ be used
79
79
-
/// to quantify "content uniqueness".
80
80
-
pub fn hash(&self) -> BookHash {
81
81
-
let mut ctx = sha1::Sha1::new();
82
82
-
83
83
-
#[inline(always)]
84
84
-
fn put_str(ctx: &mut sha1::Sha1, s: &str) {
85
85
-
ctx.update(s.as_bytes());
86
86
-
}
87
87
-
88
88
-
#[inline(always)]
89
89
-
fn put_vec_page(ctx: &mut sha1::Sha1, v: &[Component]) {
90
90
-
for c in v {
91
91
-
let s = normalize_page(c);
92
92
-
put_str(ctx, &s);
93
93
-
}
94
94
-
}
95
95
-
96
96
-
put_str(&mut ctx, &self.author);
97
97
-
put_str(&mut ctx, &self.title);
98
98
-
put_vec_page(&mut ctx, &self.pages);
99
99
-
100
100
-
ctx.finalize().0
101
101
-
}
102
102
-
}
103
103
-
104
104
-
/// NBT tag data for a shulker box.
105
105
-
#[skip_serializing_none]
106
106
-
#[derive(Debug, Clone, Serialize, Deserialize)]
107
107
-
pub struct ShulkerBoxTag {
108
108
-
pub display: Option<DisplayTag>,
109
109
-
110
110
-
#[serde(rename = "BlockEntityTag")]
111
111
-
pub block_entity: ShulkerBlockEntityTag,
112
112
-
}
113
113
-
114
114
-
/// Block entity payload for a shulker box, containing its items.
115
115
-
#[skip_serializing_none]
116
116
-
#[derive(Debug, Clone, Serialize, Deserialize)]
117
117
-
#[serde(rename_all = "PascalCase")]
118
118
-
pub struct ShulkerBlockEntityTag {
119
119
-
pub custom_name: Option<String>,
120
120
-
121
121
-
#[serde(default)]
122
122
-
pub items: Vec<ItemStack>,
123
123
-
}
124
124
-
125
125
-
/// Common display metadata (custom name, etc.).
126
126
-
#[skip_serializing_none]
127
127
-
#[derive(Debug, Clone, Serialize, Deserialize)]
128
128
-
#[serde(rename_all = "PascalCase")]
129
129
-
pub struct DisplayTag {
130
130
-
pub name: Option<String>,
131
131
-
}
+180
-188
src/library/text.rs
nara_core/src/component.rs
···
1
1
-
use html_escape::encode_text;
2
2
-
use serde::de::{
3
3
-
MapAccess, SeqAccess, Visitor,
4
4
-
value::{MapAccessDeserializer, SeqAccessDeserializer},
1
1
+
use serde::{
2
2
+
Deserialize, Deserializer, Serialize,
3
3
+
de::{
4
4
+
MapAccess, SeqAccess, Visitor,
5
5
+
value::{MapAccessDeserializer, SeqAccessDeserializer},
6
6
+
},
5
7
};
6
6
-
use serde::{Deserialize, Deserializer, Serialize};
7
8
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
13
-
Object(ComponentObject),
13
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
20
-
#[serde(default)]
21
21
-
pub text: Option<String>,
22
22
-
#[serde(default)]
23
23
-
pub color: Option<String>,
20
20
+
#[serde(flatten)]
21
21
+
text: Option<TextComponent>,
22
22
+
#[serde(flatten)]
23
23
+
translation: Option<TranslationComponent>,
24
24
+
// DO NOT FLATTEN THE SCORE COMPONENT
25
25
+
score: Option<ScoreComponent>,
26
26
+
#[serde(flatten)]
27
27
+
selector: Option<SelectorComponent>,
28
28
+
#[serde(flatten)]
29
29
+
keybind: Option<KeybindComponent>,
30
30
+
#[serde(flatten)]
31
31
+
nbt: Option<NbtComponent>,
32
32
+
#[serde(flatten)]
33
33
+
object: Option<ObjectComponent>,
34
34
+
#[serde(flatten)]
35
35
+
formatting: ComponentFormatting,
36
36
+
#[serde(default, rename = "extra", skip_serializing_if = "Vec::is_empty")]
37
37
+
children: Vec<Component>,
38
38
+
}
39
39
+
40
40
+
#[serde_with::skip_serializing_none]
41
41
+
#[derive(Debug, Clone, Serialize, Deserialize)]
42
42
+
pub struct ComponentFormatting {
43
43
+
color: Option<crate::color::Color>,
44
44
+
font: Option<String>,
45
45
+
bold: Option<bool>,
46
46
+
italic: Option<bool>,
47
47
+
underlined: Option<bool>,
48
48
+
strikethrough: Option<bool>,
49
49
+
obfuscated: Option<bool>,
50
50
+
}
51
51
+
52
52
+
#[derive(Debug, Clone, Serialize, Deserialize)]
53
53
+
pub struct TextComponent {
54
54
+
text: String,
55
55
+
}
56
56
+
57
57
+
#[serde_with::skip_serializing_none]
58
58
+
#[derive(Debug, Clone, Serialize, Deserialize)]
59
59
+
pub struct TranslationComponent {
60
60
+
translate: String,
61
61
+
fallback: Option<String>,
24
62
#[serde(default, skip_serializing_if = "Vec::is_empty")]
25
25
-
pub extra: Vec<Component>,
26
26
-
#[serde(default)]
27
27
-
pub translate: Option<String>,
28
28
-
#[serde(default, rename = "with", skip_serializing_if = "Vec::is_empty")]
29
29
-
pub with_: Vec<Component>,
63
63
+
with: Vec<Component>,
30
64
}
31
65
32
32
-
impl Component {
33
33
-
pub fn to_plain_text(&self) -> String {
34
34
-
let mut out = String::new();
35
35
-
self.push_plain_text(&mut out);
36
36
-
out
37
37
-
}
66
66
+
#[serde_with::skip_serializing_none]
67
67
+
#[derive(Debug, Clone, Serialize, Deserialize)]
68
68
+
pub struct ScoreComponent {
69
69
+
name: String,
70
70
+
objective: String,
71
71
+
}
38
72
39
39
-
pub fn to_html(&self) -> String {
40
40
-
let mut out = String::new();
41
41
-
self.push_html(&mut out, None);
42
42
-
out
43
43
-
}
73
73
+
#[serde_with::skip_serializing_none]
74
74
+
#[derive(Debug, Clone, Serialize, Deserialize)]
75
75
+
pub struct SelectorComponent {
76
76
+
selector: String,
77
77
+
separator: Option<Box<Component>>,
78
78
+
}
44
79
45
45
-
fn push_plain_text(&self, out: &mut String) {
46
46
-
match self {
47
47
-
Component::String(s) => out.push_str(s),
48
48
-
Component::Array(items) => {
49
49
-
for c in items {
50
50
-
c.push_plain_text(out);
51
51
-
}
52
52
-
}
53
53
-
Component::Object(obj) => {
54
54
-
if let Some(t) = &obj.text {
55
55
-
out.push_str(t);
56
56
-
} else if let Some(t) = &obj.translate {
57
57
-
out.push_str(t);
58
58
-
}
59
59
-
for c in &obj.with_ {
60
60
-
c.push_plain_text(out);
61
61
-
}
62
62
-
for c in &obj.extra {
63
63
-
c.push_plain_text(out);
64
64
-
}
65
65
-
}
66
66
-
}
67
67
-
}
80
80
+
#[serde_with::skip_serializing_none]
81
81
+
#[derive(Debug, Clone, Serialize, Deserialize)]
82
82
+
pub struct KeybindComponent {
83
83
+
keybind: String,
84
84
+
}
68
85
69
69
-
fn push_html<'a>(&'a self, out: &mut String, color: Option<&'a str>) {
86
86
+
#[derive(Debug, Clone, Serialize, Deserialize)]
87
87
+
#[serde(rename_all = "snake_case")]
88
88
+
pub enum NbtComponentSource {
89
89
+
Block,
90
90
+
Entity,
91
91
+
Storage,
92
92
+
}
93
93
+
94
94
+
#[serde_with::skip_serializing_none]
95
95
+
#[derive(Debug, Clone, Serialize, Deserialize)]
96
96
+
pub struct NbtComponent {
97
97
+
source: Option<NbtComponentSource>,
98
98
+
#[serde(rename = "nbt")]
99
99
+
path: String,
100
100
+
interpret: Option<bool>,
101
101
+
separator: Option<Box<Component>>,
102
102
+
entity: Option<String>,
103
103
+
block: Option<String>,
104
104
+
storage: Option<String>,
105
105
+
}
106
106
+
107
107
+
#[derive(Debug, Clone, Serialize, Deserialize)]
108
108
+
#[serde(tag = "object", rename_all = "snake_case")]
109
109
+
pub enum ObjectComponent {
110
110
+
Atlas(AtlasObject),
111
111
+
Player(PlayerObject),
112
112
+
}
113
113
+
114
114
+
#[serde_with::skip_serializing_none]
115
115
+
#[derive(Debug, Clone, Serialize, Deserialize)]
116
116
+
pub struct AtlasObject {
117
117
+
atlas: Option<String>,
118
118
+
sprite: String,
119
119
+
}
120
120
+
121
121
+
#[derive(Debug, Clone, Serialize, Deserialize)]
122
122
+
#[serde(untagged)]
123
123
+
pub enum PlayerProfileOrName {
124
124
+
Profile(crate::profile::PlayerProfile),
125
125
+
Name(String),
126
126
+
}
127
127
+
128
128
+
#[serde_with::skip_serializing_none]
129
129
+
#[derive(Debug, Clone, Serialize, Deserialize)]
130
130
+
pub struct PlayerObject {
131
131
+
player: PlayerProfileOrName,
132
132
+
hat: Option<bool>,
133
133
+
}
134
134
+
135
135
+
impl Component {
136
136
+
pub fn to_plain_text(&self) -> String {
70
137
match self {
71
71
-
Component::String(s) => {
72
72
-
push_text_with_color(out, s, color);
73
73
-
}
138
138
+
Component::String(s) => s.clone(),
74
139
Component::Array(items) => {
75
75
-
for c in items {
76
76
-
c.push_html(out, color);
77
77
-
}
140
140
+
items.iter().map(|c| c.to_plain_text()).collect()
78
141
}
79
142
Component::Object(obj) => {
80
80
-
let next_color = obj.color.as_deref().or(color);
81
81
-
if let Some(t) = &obj.text {
82
82
-
push_text_with_color(out, t, next_color);
83
83
-
} else if let Some(t) = &obj.translate {
84
84
-
push_text_with_color(out, t, next_color);
85
85
-
}
86
86
-
for c in &obj.with_ {
87
87
-
c.push_html(out, next_color);
143
143
+
let mut s =
144
144
+
obj.text.as_ref().map_or(String::new(), |t| t.text.clone());
145
145
+
for child in &obj.children {
146
146
+
s.push_str(&child.to_plain_text());
88
147
}
89
89
-
for c in &obj.extra {
90
90
-
c.push_html(out, next_color);
91
91
-
}
148
148
+
s
92
149
}
93
150
}
94
151
}
95
95
-
}
96
152
97
97
-
const SCRUBBED_GLYPH: char = '\u{f702}';
98
98
-
99
99
-
/// Removes known unwanted glyphs from text content.
100
100
-
pub fn scrub_unwanted_glyphs(raw: &str) -> String {
101
101
-
raw.chars().filter(|&c| c != SCRUBBED_GLYPH).collect()
102
102
-
}
103
103
-
104
104
-
/// Recursively removes unwanted glyphs from a component tree.
105
105
-
pub fn scrub_component(component: &mut Component) {
106
106
-
match component {
107
107
-
Component::String(s) => {
108
108
-
*s = scrub_unwanted_glyphs(s);
109
109
-
}
110
110
-
Component::Array(items) => {
111
111
-
for item in items {
112
112
-
scrub_component(item);
113
113
-
}
114
114
-
}
115
115
-
Component::Object(obj) => {
116
116
-
if let Some(text) = &mut obj.text {
117
117
-
*text = scrub_unwanted_glyphs(text);
118
118
-
}
119
119
-
if let Some(translate) = &mut obj.translate {
120
120
-
*translate = scrub_unwanted_glyphs(translate);
121
121
-
}
122
122
-
for item in &mut obj.with_ {
123
123
-
scrub_component(item);
124
124
-
}
125
125
-
for item in &mut obj.extra {
126
126
-
scrub_component(item);
127
127
-
}
128
128
-
}
153
153
+
pub fn normalize(&self) -> String {
154
154
+
let text = self.to_plain_text();
155
155
+
strip_section_codes(&text)
129
156
}
130
157
}
131
158
···
142
169
out.push(c);
143
170
}
144
171
out
145
145
-
}
146
146
-
147
147
-
/// Normalizes a component for hashing.
148
148
-
pub fn normalize_page(component: &Component) -> String {
149
149
-
let text = component.to_plain_text();
150
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
272
-
if obj.translate.is_none()
273
273
-
&& obj.with_.is_empty()
274
274
-
&& let Some(text) = obj.text.clone()
275
275
-
&& text.contains('§')
293
293
+
if obj.translation.is_none()
294
294
+
&& let Some(ref tc) = obj.text
295
295
+
&& tc.text.contains('§')
276
296
{
277
297
let expanded = parse_legacy_section_text_with_color(
278
278
-
&text,
279
279
-
obj.color.clone(),
298
298
+
&tc.text.clone(),
299
299
+
obj.formatting.color.clone(),
280
300
);
281
281
-
return merge_expanded_with_extra(expanded, obj.extra);
301
301
+
return merge_expanded_with_extra(expanded, obj.children);
282
302
}
283
283
-
obj.extra = obj.extra.into_iter().map(expand_legacy).collect();
284
284
-
obj.with_ = obj.with_.into_iter().map(expand_legacy).collect();
303
303
+
obj.children =
304
304
+
obj.children.into_iter().map(expand_legacy).collect();
305
305
+
if let Some(ref mut trans) = obj.translation {
306
306
+
trans.with = trans.with.drain(..).map(expand_legacy).collect();
307
307
+
}
285
308
Component::Object(obj)
286
309
}
287
310
}
···
307
330
}
308
331
}
309
332
333
333
+
fn make_text_object(
334
334
+
text: String,
335
335
+
color: Option<crate::color::Color>,
336
336
+
) -> Component {
337
337
+
Component::Object(Box::new(ComponentObject {
338
338
+
text: Some(TextComponent { text }),
339
339
+
translation: None,
340
340
+
score: None,
341
341
+
selector: None,
342
342
+
keybind: None,
343
343
+
nbt: None,
344
344
+
object: None,
345
345
+
formatting: ComponentFormatting {
346
346
+
color,
347
347
+
font: None,
348
348
+
bold: None,
349
349
+
italic: None,
350
350
+
underlined: None,
351
351
+
strikethrough: None,
352
352
+
obfuscated: None,
353
353
+
},
354
354
+
children: Vec::new(),
355
355
+
}))
356
356
+
}
357
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
316
-
initial_color: Option<String>,
364
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
320
-
let mut color: Option<String> = initial_color;
368
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
328
-
out.push(Component::Object(ComponentObject {
329
329
-
text: Some(std::mem::take(&mut buf)),
330
330
-
color: color.clone(),
331
331
-
extra: Vec::new(),
332
332
-
translate: None,
333
333
-
with_: Vec::new(),
334
334
-
}));
376
376
+
out.push(make_text_object(
377
377
+
std::mem::take(&mut buf),
378
378
+
color.clone(),
379
379
+
));
335
380
}
336
381
337
382
match legacy_color_name(code) {
338
338
-
Some(name) => color = Some(name),
383
383
+
Some(name) => {
384
384
+
color = crate::color::Color::try_from(name).ok();
385
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
352
-
out.push(Component::Object(ComponentObject {
353
353
-
text: Some(buf),
354
354
-
color,
355
355
-
extra: Vec::new(),
356
356
-
translate: None,
357
357
-
with_: Vec::new(),
358
358
-
}));
399
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
398
-
fn push_text_with_color(out: &mut String, text: &str, color: Option<&str>) {
399
399
-
let class_color = color
400
400
-
.and_then(sanitize_color_class)
401
401
-
.map(|c| format!("text-{}", c));
402
402
-
if let Some(class_color) = class_color {
403
403
-
out.push_str("<span class=\"");
404
404
-
out.push_str(&class_color);
405
405
-
out.push_str("\">");
406
406
-
push_escaped_with_breaks(out, text);
407
407
-
out.push_str("</span>");
408
408
-
} else {
409
409
-
push_escaped_with_breaks(out, text);
410
410
-
}
411
411
-
}
412
412
-
413
413
-
fn sanitize_color_class(color: &str) -> Option<&str> {
414
414
-
if color.is_empty() {
415
415
-
return None;
416
416
-
}
417
417
-
if color
418
418
-
.chars()
419
419
-
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
420
420
-
{
421
421
-
Some(color)
422
422
-
} else {
423
423
-
None
424
424
-
}
425
425
-
}
426
426
-
427
427
-
fn push_escaped_with_breaks(out: &mut String, text: &str) {
428
428
-
let mut first = true;
429
429
-
for part in text.split('\n') {
430
430
-
if !first {
431
431
-
out.push_str("<br>");
432
432
-
}
433
433
-
first = false;
434
434
-
out.push_str(encode_text(part).as_ref());
435
435
-
}
436
436
-
}
437
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
486
-
Ok(expand_legacy(Component::Object(obj)))
487
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
501
-
assert_eq!(normalize_page(&c), "hello world");
502
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
507
-
assert_eq!(normalize_page(&c), "hello world");
508
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
514
-
assert_eq!(normalize_page(&c), "hi there");
515
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
520
-
assert_eq!(normalize_page(&c), "hello");
521
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
539
-
assert_eq!(normalize_page(&c), "hello world");
540
540
-
}
541
541
-
542
542
-
#[test]
543
543
-
fn scrub_unwanted_glyph_from_component_tree() {
544
544
-
let mut c = parse_component_from_str(
545
545
-
r#"[{"text":"hi there"},{"text":" and more"}]"#,
546
546
-
);
547
547
-
scrub_component(&mut c);
548
548
-
assert_eq!(c.to_plain_text(), "hi there and more");
540
540
+
assert_eq!(c.normalize(), "hello world");
549
541
}
550
542
}
+2
-28
src/main.rs
···
1
1
-
use std::{fs, io::Cursor};
2
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
8
-
library::{Library, Realm},
6
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
39
-
let realm_path = &args.realm_path;
40
40
-
41
41
-
if !fs::exists(realm_path)
42
42
-
.context("Checking if supplied realm path exists")?
43
43
-
{
44
44
-
return Err(anyhow::anyhow!("File {:?} not found", realm_path));
45
45
-
}
46
46
-
47
47
-
let bytes = fs::read(realm_path).context("Reading realm")?;
48
48
-
let mut cursor: Cursor<&[u8]> = Cursor::new(&bytes);
49
49
-
let realm = crab_nbt::serde::de::from_cursor::<Realm>(&mut cursor)
50
50
-
.context("Reading realm NBT")?;
51
51
-
52
52
-
let library = Library::new(
53
53
-
realm,
54
54
-
args.content_threshold,
55
55
-
args.author_threshold,
56
56
-
args.title_threshold,
57
57
-
args.warn_duplicates,
58
58
-
args.warn_empty,
59
59
-
args.filter_empty_books,
60
60
-
)
61
61
-
.context("Constructing library")?;
62
62
-
63
63
-
Ok(library)
37
37
+
todo!()
64
38
}
+6
-22
src/web/api.rs
···
7
7
response::{IntoResponse, Response},
8
8
routing::get,
9
9
};
10
10
+
use nara_core::book::Book;
10
11
use serde::{Deserialize, Serialize};
11
12
12
12
-
use crate::library::{self, Library, item::WrittenBookTag, text::Component};
13
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
255
-
search_fn: for<'a> fn(
256
256
-
&'a Library,
257
257
-
&str,
258
258
-
usize,
259
259
-
) -> Vec<(&'a WrittenBookTag, f64)>,
256
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
284
-
title: String,
285
285
-
author: String,
286
286
-
pages: Vec<Component>,
281
281
+
book: Book,
287
282
hash: String,
288
288
-
location: Option<String>,
289
289
-
category: Option<String>,
290
283
}
291
284
292
292
-
fn book_to_detail(book: &WrittenBookTag, library: &Library) -> BookDetail {
285
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
295
-
let location = library.location_for_hash(&hash).map(|s| s.to_string());
296
296
-
let category = location
297
297
-
.as_deref()
298
298
-
.and_then(library::category_from_location)
299
299
-
.map(|s| s.to_string());
300
288
301
289
BookDetail {
302
302
-
title: book.title.clone(),
303
303
-
author: book.author.clone(),
304
304
-
pages: book.pages.clone(),
290
290
+
book: book.clone(),
305
291
hash: hash_hex,
306
306
-
location,
307
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
14
+
use nara_core::book::{Book, BookSource};
14
15
use serde::Deserialize;
15
16
use smol_str::SmolStr;
16
17
17
17
-
use crate::{
18
18
-
library::{Library, category_from_location, item::WrittenBookTag},
19
19
-
web::TextureKind,
20
20
-
};
18
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
111
-
pub location: String,
112
112
-
pub has_location: bool,
109
109
+
pub source: BookSource,
113
110
pub category: String,
114
111
pub category_href: String,
115
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
363
-
) -> Vec<&'a WrittenBookTag> {
359
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
444
-
let raw = book.author.trim();
440
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
556
-
location: String,
557
557
-
has_location: bool,
558
552
category: String,
559
559
-
has_category: bool,
560
553
category_href: String,
561
554
}
562
555
563
563
-
fn book_meta(book: &WrittenBookTag, library: &Library) -> BookMeta {
564
564
-
let hash = book.hash();
565
565
-
let location = library
566
566
-
.location_for_hash(&hash)
567
567
-
.map(|s| s.to_string())
568
568
-
.unwrap_or_default();
569
569
-
let has_location = !location.is_empty();
570
570
-
let category = if has_location {
571
571
-
category_from_location(&location)
572
572
-
.map(|s| s.to_string())
573
573
-
.unwrap_or_default()
574
574
-
} else {
575
575
-
String::new()
576
576
-
};
577
577
-
let has_category = !category.is_empty();
578
578
-
let category_href = if has_category {
579
579
-
format!("/?category={0}", encode_query_component(&category))
580
580
-
} else {
581
581
-
String::new()
582
582
-
};
556
556
+
fn book_meta(book: &Book, library: &Library) -> BookMeta {
557
557
+
// let hash = book.hash();
558
558
+
let category: String = todo!();
559
559
+
560
560
+
let category_href =
561
561
+
format!("/?category={0}", encode_query_component(&category));
583
562
let author_href =
584
584
-
format!("/?author={0}", encode_query_component(&book.author));
563
563
+
format!("/?author={0}", encode_query_component(&book.content.author));
585
564
586
565
BookMeta {
587
566
author_href,
588
588
-
location,
589
589
-
has_location,
590
567
category,
591
591
-
has_category,
592
568
category_href,
593
569
}
594
570
}
595
571
596
596
-
fn book_card(book: &WrittenBookTag) -> BookCard {
572
572
+
fn book_card(book: &Book) -> BookCard {
597
573
let hash_hex = hex::encode(book.hash());
598
574
let author_href =
599
599
-
format!("/?author={0}", encode_query_component(&book.author));
575
575
+
format!("/?author={0}", encode_query_component(&book.content.author));
600
576
601
577
BookCard {
602
602
-
title: book.title.clone(),
603
603
-
author: book.author.clone(),
578
578
+
title: book.content.title.clone(),
579
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
610
-
fn book_detail(book: &WrittenBookTag, library: &Library) -> BookDetail {
586
586
+
fn book_detail(book: &Book, library: &Library) -> BookDetail {
611
587
let meta = book_meta(book, library);
612
588
let pages = book
589
589
+
.content
613
590
.pages
614
591
.iter()
615
592
.enumerate()
616
593
.map(|(idx, page)| PageView {
617
594
index: idx + 1,
618
618
-
html: HtmlText(page.to_html()),
595
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
623
-
title: book.title.clone(),
624
624
-
author: book.author.clone(),
600
600
+
title: book.content.title.clone(),
601
601
+
author: book.content.author.clone(),
625
602
author_href: meta.author_href,
626
626
-
location: meta.location,
627
627
-
has_location: meta.has_location,
603
603
+
source: todo!(),
628
604
category: meta.category,
629
605
category_href: meta.category_href,
630
630
-
has_category: meta.has_category,
631
606
pages,
632
632
-
page_count: book.pages.len(),
607
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
664
-
) -> Vec<&'a WrittenBookTag> {
639
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
689
-
) -> Vec<&'a WrittenBookTag> {
664
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
699
-
let title_norm = normalize_query(&book.title);
674
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
724
-
fn matches_author(book: &WrittenBookTag, author_norm: Option<&str>) -> bool {
699
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
728
-
book.author.trim().eq_ignore_ascii_case(author_norm)
703
703
+
book.content.author.trim().eq_ignore_ascii_case(author_norm)
729
704
}
730
705
731
706
fn matches_category(
732
732
-
book: &WrittenBookTag,
707
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
739
-
let Some(location) = library.location_for_hash(&book.hash()) else {
740
740
-
return false;
741
741
-
};
742
742
-
let Some(category) = category_from_location(location) else {
743
743
-
return false;
744
744
-
};
745
745
-
category.trim().eq_ignore_ascii_case(category_norm)
714
714
+
todo!()
746
715
}
747
716
748
717
fn parse_hash(hex_str: &str) -> Option<[u8; 20]> {
+53
-43
templates/book.html
···
1
1
-
{% extends "base.html" %}
2
2
-
3
3
-
{% block title %}{{ book.title }} - nara{% endblock %}
1
1
+
{% extends "base.html" %} {% block title %}{{ book.title }} - nara{% endblock %}
4
2
{% block head %}
5
5
-
<link rel="icon" type="image/webp" href="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" />
3
3
+
<link
4
4
+
rel="icon"
5
5
+
type="image/webp"
6
6
+
href="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}"
7
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
9
-
<meta property="og:description" content="By {{ book.author }}. {{ book.page_count }} pages." />
11
11
+
<meta
12
12
+
property="og:description"
13
13
+
content="By {{ book.author }}. {{ book.page_count }} pages."
14
14
+
/>
10
15
<meta name="twitter:card" content="summary_large_image" />
11
16
<meta name="twitter:title" content="{{ book.title }} - nara" />
12
12
-
<meta name="twitter:description" content="By {{ book.author }}. {{ book.page_count }} pages." />
13
13
-
{% endblock %}
14
14
-
15
15
-
{% block page_class %}page detail{% endblock %}
16
16
-
{% block page_attrs %} style="--book-mask: url('/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}')"{% endblock %}
17
17
-
18
18
-
{% block body %}
17
17
+
<meta
18
18
+
name="twitter:description"
19
19
+
content="By {{ book.author }}. {{ book.page_count }} pages."
20
20
+
/>
21
21
+
{% endblock %} {% block page_class %}page detail{% endblock %} {% block
22
22
+
page_attrs %} style="--book-mask: url('/assets/image/{{ texture_kind
23
23
+
}}/written_book.webp?v={{ crate::VERSION }}')"{% endblock %} {% block body %}
19
24
<header class="hero detail-hero">
20
20
-
<div class="brand">
21
21
-
<span class="enchanted book-badge">
22
22
-
<img class="item" src="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}" alt="">
23
23
-
</span>
24
24
-
<div>
25
25
-
<p class="eyebrow">Book Detail</p>
26
26
-
<h1 class="font-minecraft">{{ book.title }}</h1>
27
27
-
<p class="meta">by <a href="{{ book.author_href }}">{{ book.author }}</a></p>
28
28
-
<div class="meta-row">
29
29
-
{% if book.has_category %}
30
30
-
<a class="tag-link" href="{{ book.category_href }}"><span class="tag">{{ book.category }}</span></a>
31
31
-
{% endif %}
32
32
-
{% if book.has_location %}
33
33
-
<span class="tag subtle">{{ book.location }}</span>
34
34
-
{% endif %}
35
35
-
<span class="tag subtle">{{ book.page_count }} pages</span>
36
36
-
</div>
25
25
+
<div class="brand">
26
26
+
<span class="enchanted book-badge">
27
27
+
<img
28
28
+
class="item"
29
29
+
src="/assets/image/{{ texture_kind }}/written_book.webp?v={{ crate::VERSION }}"
30
30
+
alt=""
31
31
+
/>
32
32
+
</span>
33
33
+
<div>
34
34
+
<p class="eyebrow">Book Detail</p>
35
35
+
<h1 class="font-minecraft">{{ book.title }}</h1>
36
36
+
<p class="meta">
37
37
+
by <a href="{{ book.author_href }}">{{ book.author }}</a>
38
38
+
</p>
39
39
+
<div class="meta-row">
40
40
+
<a class="tag-link" href="{{ book.category_href }}"
41
41
+
><span class="tag">{{ book.category }}</span></a
42
42
+
>
43
43
+
<span class="tag subtle">{{ book.page_count }} pages</span>
44
44
+
</div>
45
45
+
</div>
37
46
</div>
38
38
-
</div>
39
39
-
<div class="detail-actions">
40
40
-
<a class="button ghost" href="{{ back_href }}">Back to list</a>
41
41
-
</div>
47
47
+
<div class="detail-actions">
48
48
+
<a class="button ghost" href="{{ back_href }}">Back to list</a>
49
49
+
</div>
42
50
</header>
43
51
44
52
<section class="detail-body">
45
45
-
<div class="pages">
46
46
-
{% for page in book.pages %}
47
47
-
<article class="book-page">
48
48
-
<div class="book-page-sprite">
49
49
-
<p class="book-page-header font-minecraft">Page {{ page.index }} of {{ book.page_count }}</p>
50
50
-
<div class="book-page-text font-minecraft">{{ page.html }}</div>
51
51
-
</div>
52
52
-
</article>
53
53
-
{% endfor %}
54
54
-
</div>
53
53
+
<div class="pages">
54
54
+
{% for page in book.pages %}
55
55
+
<article class="book-page">
56
56
+
<div class="book-page-sprite">
57
57
+
<p class="book-page-header font-minecraft">
58
58
+
Page {{ page.index }} of {{ book.page_count }}
59
59
+
</p>
60
60
+
<div class="book-page-text font-minecraft">{{ page.html }}</div>
61
61
+
</div>
62
62
+
</article>
63
63
+
{% endfor %}
64
64
+
</div>
55
65
</section>
56
66
{% endblock %}