1pub mod font;
7pub mod renderer;
8pub mod state;
9pub mod theme;
10
11use serde::{Deserialize, Serialize};
12use yog_registry::ItemDef;
13
14pub use font::BookFont;
15pub use renderer::{BOOK_PNG, BOOK_SHEET, BookRenderer, CRAFTING_PNG, CRAFTING_SHEET};
16pub use state::BookViewState;
17pub use theme::BookTheme;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct BookMacro(pub String, pub String);
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "type", rename_all = "snake_case")]
36pub enum BookPage {
37 Text {
39 text: String,
40 #[serde(default)]
41 title: Option<String>,
42 },
43 Spotlight {
45 #[serde(with = "item_ref")]
48 item: ItemDef,
49 #[serde(default)]
50 title: Option<String>,
51 #[serde(default)]
52 text: Option<String>,
53 },
54 Crafting {
56 #[serde(rename = "recipe")]
57 recipe_id: String,
58 #[serde(default)]
59 text: Option<String>,
60 },
61 Smelting {
63 #[serde(rename = "recipe")]
64 recipe_id: String,
65 #[serde(default)]
66 text: Option<String>,
67 },
68 Image {
70 texture: String,
71 #[serde(default)]
72 title: Option<String>,
73 #[serde(default)]
74 text: Option<String>,
75 #[serde(default)]
76 border: bool,
77 },
78 Entity {
80 #[serde(rename = "entity")]
81 entity_type: String,
82 #[serde(default)]
83 name: Option<String>,
84 #[serde(default)]
85 text: Option<String>,
86 },
87 Relations {
89 entries: Vec<String>,
90 #[serde(default)]
91 text: Option<String>,
92 },
93 Empty,
95 Pattern {
97 op_id: String,
98 #[serde(default)]
99 anchor: String,
100 #[serde(default)]
101 input: String,
102 #[serde(default)]
103 output: String,
104 #[serde(default)]
105 text: String,
106 },
107 Svg {
109 data: String,
111 #[serde(default)]
112 title: Option<String>,
113 #[serde(default)]
114 text: Option<String>,
115 },
116 CustomText {
118 text: String,
119 #[serde(flatten)]
121 font: BookFont,
122 color: u32,
124 },
125}
126
127mod item_ref {
130 use serde::{Deserialize, Deserializer, Serializer};
131 use yog_registry::ItemDef;
132
133 pub fn serialize<S: Serializer>(item: &ItemDef, s: S) -> Result<S::Ok, S::Error> {
134 s.serialize_str(&item.id)
135 }
136
137 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ItemDef, D::Error> {
138 #[derive(Deserialize)]
139 #[serde(untagged)]
140 enum Repr {
141 Id(String),
142 Def(ItemDef),
143 }
144 Ok(match Repr::deserialize(d)? {
145 Repr::Id(id) => ItemDef::new(id),
146 Repr::Def(def) => def,
147 })
148 }
149}
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
155#[serde(default)]
156pub struct BookCategory {
157 pub id: String,
158 pub name: String,
159 pub description: Option<String>,
160 pub icon: Option<String>,
162 pub icon_svg: Option<String>,
164 pub sortnum: i32,
166}
167
168#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172#[serde(default)]
173pub struct BookEntry {
174 pub id: String,
175 pub name: String,
176 pub category: String,
177 pub pages: Vec<BookPage>,
178 pub icon: Option<String>,
180 pub icon_svg: Option<String>,
182 pub secret: bool,
184 pub priority: i32,
186 pub read_by_default: bool,
188 pub advancement: Option<String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(default)]
197pub struct Book {
198 pub id: String,
199 pub name: String,
200 pub nameplate_color: String,
201 pub landing_text: String,
202 pub author: Option<String>,
203 pub book_texture: String,
204 pub filler_texture: String,
205 pub model: String,
206 pub categories: Vec<BookCategory>,
207 pub entries: Vec<BookEntry>,
208 pub macros: Vec<BookMacro>,
209 pub use_resource_pack: bool,
210 pub show_progress: bool,
211 pub i18n: bool,
212 pub creative_tab: Option<String>,
213}
214
215impl Book {
216 pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
217 Self {
218 id: id.into(),
219 name: name.into(),
220 nameplate_color: "FFDD98".into(), landing_text: String::new(),
222 author: None,
223 book_texture: "yog:gui/book".into(),
224 filler_texture: "yog:gui/book_filler".into(),
225 model: "minecraft:book".into(),
226 categories: Vec::new(),
227 entries: Vec::new(),
228 macros: Vec::new(),
229 use_resource_pack: false,
230 show_progress: true,
231 i18n: false,
232 creative_tab: None,
233 }
234 }
235
236 pub fn author(mut self, author: impl Into<String>) -> Self {
237 self.author = Some(author.into());
238 self
239 }
240
241 pub fn book_texture(mut self, tex: impl Into<String>) -> Self {
242 self.book_texture = tex.into();
243 self
244 }
245
246 pub fn filler_texture(mut self, tex: impl Into<String>) -> Self {
247 self.filler_texture = tex.into();
248 self
249 }
250
251 pub fn nameplate(mut self, color: impl Into<String>) -> Self {
252 self.nameplate_color = color.into();
253 self
254 }
255
256 pub fn landing_text(mut self, text: impl Into<String>) -> Self {
257 self.landing_text = text.into();
258 self
259 }
260
261 pub fn model(mut self, model: impl Into<String>) -> Self {
262 self.model = model.into();
263 self
264 }
265
266 pub fn creative_tab(mut self, tab: impl Into<String>) -> Self {
267 self.creative_tab = Some(tab.into());
268 self
269 }
270
271 pub fn show_progress(mut self, show: bool) -> Self {
272 self.show_progress = show;
273 self
274 }
275
276 pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
281 let opt = |o: &mut Option<String>| {
282 if let Some(t) = o {
283 *t = tr(t);
284 }
285 };
286 let mut b = self.clone();
287 b.name = tr(&b.name);
288 b.landing_text = tr(&b.landing_text);
289 for c in &mut b.categories {
290 c.name = tr(&c.name);
291 opt(&mut c.description);
292 }
293 for e in &mut b.entries {
294 e.name = tr(&e.name);
295 for p in &mut e.pages {
296 match p {
297 BookPage::Text { text, title } => {
298 *text = tr(text);
299 opt(title);
300 }
301 BookPage::Spotlight { title, text, .. }
302 | BookPage::Image { title, text, .. }
303 | BookPage::Svg { title, text, .. } => {
304 opt(title);
305 opt(text);
306 }
307 BookPage::Entity { name, text, .. } => {
308 opt(name);
309 opt(text);
310 }
311 BookPage::Crafting { text, .. }
312 | BookPage::Smelting { text, .. }
313 | BookPage::Relations { text, .. } => opt(text),
314 BookPage::Pattern { text, .. } | BookPage::CustomText { text, .. } => {
315 *text = tr(text)
316 }
317 BookPage::Empty => {}
318 }
319 }
320 }
321 b
322 }
323
324 pub fn i18n(mut self, val: bool) -> Self {
325 self.i18n = val;
326 self
327 }
328
329 pub fn use_resource_pack(mut self, val: bool) -> Self {
330 self.use_resource_pack = val;
331 self
332 }
333
334 pub fn add_macro(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
335 self.macros.push(BookMacro(key.into(), value.into()));
336 self
337 }
338
339 pub fn add_category(mut self, category: BookCategory) -> Self {
340 self.categories.push(category);
341 self
342 }
343
344 pub fn add_entry(mut self, entry: BookEntry) -> Self {
345 self.entries.push(entry);
346 self
347 }
348}
349
350impl Default for Book {
351 fn default() -> Self {
352 Self::new("yog:default", "Unknown Book")
353 }
354}
355
356#[derive(Debug, Default)]
360pub struct BookRegistry {
361 books: std::collections::HashMap<String, Book>,
362}
363
364impl BookRegistry {
365 pub fn register(&mut self, book: Book) {
366 self.books.insert(book.id.clone(), book);
367 }
368
369 pub fn get(&self, id: &str) -> Option<&Book> {
370 self.books.get(id)
371 }
372
373 pub fn all(&self) -> impl Iterator<Item = &Book> {
374 self.books.values()
375 }
376}
377
378pub fn text_page(text: impl Into<String>) -> BookPage {
381 BookPage::Text {
382 text: text.into(),
383 title: None,
384 }
385}
386
387pub fn text_page_titled(title: impl Into<String>, text: impl Into<String>) -> BookPage {
388 BookPage::Text {
389 text: text.into(),
390 title: Some(title.into()),
391 }
392}
393
394pub fn spotlight_page(item: ItemDef) -> BookPage {
395 BookPage::Spotlight {
396 item,
397 title: None,
398 text: None,
399 }
400}
401
402pub fn crafting_page(recipe_id: impl Into<String>) -> BookPage {
403 BookPage::Crafting {
404 recipe_id: recipe_id.into(),
405 text: None,
406 }
407}
408
409pub fn crafting_page_with_text(recipe_id: impl Into<String>, text: impl Into<String>) -> BookPage {
410 BookPage::Crafting {
411 recipe_id: recipe_id.into(),
412 text: Some(text.into()),
413 }
414}
415
416pub fn smelting_page(recipe_id: impl Into<String>) -> BookPage {
417 BookPage::Smelting {
418 recipe_id: recipe_id.into(),
419 text: None,
420 }
421}
422
423pub fn image_page(texture: impl Into<String>) -> BookPage {
424 BookPage::Image {
425 texture: texture.into(),
426 title: None,
427 text: None,
428 border: true,
429 }
430}
431
432pub fn entity_page(entity_type: impl Into<String>) -> BookPage {
433 BookPage::Entity {
434 entity_type: entity_type.into(),
435 name: None,
436 text: None,
437 }
438}
439
440pub fn relations_page(entries: Vec<String>) -> BookPage {
441 BookPage::Relations {
442 entries,
443 text: None,
444 }
445}
446
447pub fn pattern_page(
448 op_id: impl Into<String>,
449 anchor: impl Into<String>,
450 input: impl Into<String>,
451 output: impl Into<String>,
452 text: impl Into<String>,
453) -> BookPage {
454 BookPage::Pattern {
455 op_id: op_id.into(),
456 anchor: anchor.into(),
457 input: input.into(),
458 output: output.into(),
459 text: text.into(),
460 }
461}
462
463pub mod book_ui {
465 use crate::{Book, BookEntry, BookPage};
466 use yog_ui::widget::{self, Widget};
467 use yog_ui::{Align, FlexDir, UiRoot};
468
469 pub fn build_book_ui(
473 book: &Book,
474 selected_cat: usize,
475 selected_entry: usize,
476 current_page: usize,
477 ) -> UiRoot {
478 let mut cats: Vec<Widget> = Vec::new();
479 for (i, cat) in book.categories.iter().enumerate() {
480 let color = if i == selected_cat {
481 0xFF_FFFF55
482 } else {
483 0xFF_CCCCCC
484 };
485 cats.push(
486 widget::button(&cat.name)
487 .color(color)
488 .on_click(format!("cat:{}", i)),
489 );
490 }
491
492 let cat = book.categories.get(selected_cat);
493 let mut entries: Vec<Widget> = Vec::new();
494 if let Some(cat) = cat {
495 let cat_entries: Vec<&BookEntry> = book
496 .entries
497 .iter()
498 .filter(|e| e.category == cat.id)
499 .collect();
500 for (i, entry) in cat_entries.iter().enumerate() {
501 let color = if i == selected_entry {
502 0xFF_FFFF55
503 } else {
504 0xFF_CCCCCC
505 };
506 let label = if entry.name.len() > 14 {
507 &entry.name[..14]
508 } else {
509 &entry.name
510 };
511 entries.push(
512 widget::button(label)
513 .color(color)
514 .on_click(format!("entry:{}", i)),
515 );
516 }
517 }
518
519 let mut pages: Vec<Widget> = Vec::new();
520 if let Some(cat) = cat {
521 let cat_entries: Vec<&BookEntry> = book
522 .entries
523 .iter()
524 .filter(|e| e.category == cat.id)
525 .collect();
526 if let Some(entry) = cat_entries.get(selected_entry)
527 && let Some(page) = entry.pages.get(current_page)
528 {
529 pages.push(render_page(page));
530 }
531 }
532
533 let nav = widget::panel(FlexDir::Row)
534 .gap(4.0)
535 .child(widget::button("<").w(28.0).on_click("prev_page"))
536 .child(
537 widget::label(format!(
538 "{}/{}",
539 current_page + 1,
540 cat.map_or(0, |c| {
541 book.entries
542 .iter()
543 .filter(|e| e.category == c.id)
544 .nth(selected_entry)
545 .map_or(0, |e| e.pages.len())
546 })
547 ))
548 .color(0xFF_888888)
549 .flex(1.0)
550 .align(Align::Center),
551 )
552 .child(widget::button(">").w(28.0).on_click("next_page"));
553
554 UiRoot::new(
555 &book.id,
556 widget::panel(FlexDir::Row)
557 .gap(2.0)
558 .padding(2.0, 2.0, 2.0, 2.0)
559 .bg(0xFF_2A1A0E)
560 .child(
561 widget::panel(FlexDir::Column)
562 .w(104.0)
563 .child(widget::label("Categories").color(0xFF_888888))
564 .child(widget::panel(FlexDir::Column).gap(1.0).child_many(cats))
565 .child(widget::label("Entries").color(0xFF_888888))
566 .child(widget::panel(FlexDir::Column).gap(1.0).child_many(entries)),
567 )
568 .child(
569 widget::panel(FlexDir::Column)
570 .flex(1.0)
571 .gap(2.0)
572 .child(widget::panel(FlexDir::Column).flex(1.0).child_many(pages))
573 .child(nav),
574 ),
575 )
576 }
577
578 fn render_page(page: &BookPage) -> Widget {
579 match page {
580 BookPage::Text { text, .. } => widget::label(text).color(0xFF_CCCCAA),
581 BookPage::Spotlight { item, title, text } => {
582 let mut p = widget::panel(FlexDir::Column).gap(2.0);
583 if let Some(t) = title {
584 p = p.child(widget::label(t).color(0xFF_FFFF55));
585 }
586 p = p.child(widget::item_slot(&item.id));
587 if let Some(t) = text {
588 p = p.child(widget::label(t).color(0xFF_CCCCAA));
589 }
590 p
591 }
592 BookPage::Crafting { recipe_id, text } => {
593 let mut p = widget::panel(FlexDir::Column).gap(2.0);
594 p = p.child(widget::label(format!("Crafting: {}", recipe_id)).color(0xFF_888888));
595 if let Some(t) = text {
596 p = p.child(widget::label(t).color(0xFF_CCCCAA));
597 }
598 p
599 }
600 BookPage::Smelting { recipe_id, text } => {
601 let mut p = widget::panel(FlexDir::Column).gap(2.0);
602 p = p.child(widget::label(format!("Smelting: {}", recipe_id)).color(0xFF_888888));
603 if let Some(t) = text {
604 p = p.child(widget::label(t).color(0xFF_CCCCAA));
605 }
606 p
607 }
608 BookPage::Empty => widget::spacer(),
609 _ => widget::label("(unsupported page)").color(0xFF_888888),
610 }
611 }
612
613 trait WidgetExt {
615 fn child_many(self, children: Vec<Widget>) -> Self;
616 }
617 impl WidgetExt for Widget {
618 fn child_many(mut self, children: Vec<Widget>) -> Self {
619 for c in children {
620 self = self.child(c);
621 }
622 self
623 }
624 }
625}
626
627fn esc(s: &str) -> String {
630 let mut out = String::with_capacity(s.len());
631 for ch in s.chars() {
632 match ch {
633 '\\' => out.push_str("\\\\"),
634 '"' => out.push_str("\\\""),
635 '\n' => out.push_str("\\n"),
636 '\r' => out.push_str("\\r"),
637 '\t' => out.push_str("\\t"),
638 c if c.is_control() => {
639 out.push_str(&format!("\\u{:04x}", c as u32));
640 }
641 _ => out.push(ch),
642 }
643 }
644 out
645}
646
647impl BookPage {
648 pub fn to_json(&self) -> String {
649 match self {
650 Self::Text { text, title } => {
651 let t = title
652 .as_deref()
653 .map(|s| format!(r#","title":"{}""#, esc(s)))
654 .unwrap_or_default();
655 format!(r#"{{"type":"text","text":"{}"{}}}"#, esc(text), t)
656 }
657 Self::Spotlight { item, title, text } => {
658 let t = title
659 .as_deref()
660 .map(|s| format!(r#","title":"{}""#, esc(s)))
661 .unwrap_or_default();
662 let tx = text
663 .as_deref()
664 .map(|s| format!(r#","text":"{}""#, esc(s)))
665 .unwrap_or_default();
666 format!(
667 r#"{{"type":"spotlight","item":"{id}"{t}{tx}}}"#,
668 id = esc(&item.id)
669 )
670 }
671 Self::Crafting { recipe_id, text } => {
672 let tx = text
673 .as_deref()
674 .map(|s| format!(r#","text":"{}""#, esc(s)))
675 .unwrap_or_default();
676 format!(
677 r#"{{"type":"crafting","recipe":"{}"{}}}"#,
678 esc(recipe_id),
679 tx
680 )
681 }
682 Self::Smelting { recipe_id, text } => {
683 let tx = text
684 .as_deref()
685 .map(|s| format!(r#","text":"{}""#, esc(s)))
686 .unwrap_or_default();
687 format!(
688 r#"{{"type":"smelting","recipe":"{}"{}}}"#,
689 esc(recipe_id),
690 tx
691 )
692 }
693 Self::Image {
694 texture,
695 title,
696 text,
697 border,
698 } => {
699 let t = title
700 .as_deref()
701 .map(|s| format!(r#","title":"{}""#, esc(s)))
702 .unwrap_or_default();
703 let tx = text
704 .as_deref()
705 .map(|s| format!(r#","text":"{}""#, esc(s)))
706 .unwrap_or_default();
707 format!(
708 r#"{{"type":"image","texture":"{}","border":{}{}{}}}"#,
709 esc(texture),
710 border,
711 t,
712 tx
713 )
714 }
715 Self::Entity {
716 entity_type,
717 name,
718 text,
719 } => {
720 let n = name
721 .as_deref()
722 .map(|s| format!(r#","name":"{}""#, esc(s)))
723 .unwrap_or_default();
724 let tx = text
725 .as_deref()
726 .map(|s| format!(r#","text":"{}""#, esc(s)))
727 .unwrap_or_default();
728 format!(
729 r#"{{"type":"entity","entity":"{}"{}{}}}"#,
730 esc(entity_type),
731 n,
732 tx
733 )
734 }
735 Self::Relations { entries, text } => {
736 let e: String = entries
737 .iter()
738 .map(|s| format!(r#""{}""#, esc(s)))
739 .collect::<Vec<_>>()
740 .join(",");
741 let tx = text
742 .as_deref()
743 .map(|s| format!(r#","text":"{}""#, esc(s)))
744 .unwrap_or_default();
745 format!(r#"{{"type":"relations","entries":[{}]{}}}"#, e, tx)
746 }
747 Self::Empty => r#"{"type":"empty"}"#.to_string(),
748 Self::Pattern {
749 op_id,
750 anchor,
751 input,
752 output,
753 text,
754 } => format!(
755 r#"{{"type":"pattern","op_id":"{}","anchor":"{}","input":"{}","output":"{}","text":"{}"}}"#,
756 esc(op_id),
757 esc(anchor),
758 esc(input),
759 esc(output),
760 esc(text)
761 ),
762 Self::Svg { data, title, text } => {
763 let t = title
764 .as_deref()
765 .map(|s| format!(r#","title":"{}""#, esc(s)))
766 .unwrap_or_default();
767 let tx = text
768 .as_deref()
769 .map(|s| format!(r#","text":"{}""#, esc(s)))
770 .unwrap_or_default();
771 format!(r#"{{"type":"svg","data":"{}"{}{}}}"#, esc(data), t, tx)
772 }
773 Self::CustomText { text, font, color } => format!(
774 r#"{{"type":"custom_text","text":"{}","font_id":"{}","size_px":{},"color":{}}}"#,
775 esc(text),
776 esc(&font.font_id),
777 font.size_px,
778 color
779 ),
780 }
781 }
782}
783
784impl BookEntry {
785 pub fn to_json(&self) -> String {
786 let pages: String = self
787 .pages
788 .iter()
789 .map(|p| p.to_json())
790 .collect::<Vec<_>>()
791 .join(",");
792 let icon = self
793 .icon
794 .as_deref()
795 .map(|s| format!(r#","icon":"{}""#, esc(s)))
796 .unwrap_or_default();
797 let adv = self
798 .advancement
799 .as_deref()
800 .map(|s| format!(r#","advancement":"{}""#, esc(s)))
801 .unwrap_or_default();
802 format!(
803 r#"{{"id":"{}","name":"{}","category":"{}","pages":[{}],"secret":{},"priority":{},"read_by_default":{}{}{}}}"#,
804 esc(&self.id),
805 esc(&self.name),
806 esc(&self.category),
807 pages,
808 self.secret,
809 self.priority,
810 self.read_by_default,
811 icon,
812 adv
813 )
814 }
815}
816
817impl BookCategory {
818 pub fn to_json(&self) -> String {
819 let desc = self
820 .description
821 .as_deref()
822 .map(|s| format!(r#","description":"{}""#, esc(s)))
823 .unwrap_or_default();
824 let icon = self
825 .icon
826 .as_deref()
827 .map(|s| format!(r#","icon":"{}""#, esc(s)))
828 .unwrap_or_default();
829 format!(
830 r#"{{"id":"{}","name":"{}","sortnum":{}{}{}}}"#,
831 esc(&self.id),
832 esc(&self.name),
833 self.sortnum,
834 desc,
835 icon
836 )
837 }
838}
839
840impl Book {
841 pub fn to_json(&self) -> String {
842 let cats: String = self
843 .categories
844 .iter()
845 .map(|c| c.to_json())
846 .collect::<Vec<_>>()
847 .join(",");
848 let entries: String = self
849 .entries
850 .iter()
851 .map(|e| e.to_json())
852 .collect::<Vec<_>>()
853 .join(",");
854 let author = self
855 .author
856 .as_deref()
857 .map(|s| format!(r#","author":"{}""#, esc(s)))
858 .unwrap_or_default();
859 let tab = self
860 .creative_tab
861 .as_deref()
862 .map(|s| format!(r#","creative_tab":"{}""#, esc(s)))
863 .unwrap_or_default();
864 format!(
865 r#"{{"id":"{}","name":"{}","nameplate_color":"{}","landing_text":"{}","book_texture":"{}","filler_texture":"{}","model":"{}","show_progress":{},"i18n":{},"use_resource_pack":{},"categories":[{}],"entries":[{}]{}{}}}"#,
866 esc(&self.id),
867 esc(&self.name),
868 esc(&self.nameplate_color),
869 esc(&self.landing_text),
870 esc(&self.book_texture),
871 esc(&self.filler_texture),
872 esc(&self.model),
873 self.show_progress,
874 self.i18n,
875 self.use_resource_pack,
876 cats,
877 entries,
878 author,
879 tab
880 )
881 }
882}
883#[cfg(test)]
886mod wire_format_tests {
887 use super::*;
888
889 #[test]
892 fn book_to_json_roundtrips_through_serde() {
893 let book = Book::new("yog:test", "Test Book")
894 .author("Tester")
895 .landing_text("hello")
896 .add_category(BookCategory {
897 id: "c1".into(),
898 name: "Cat".into(),
899 description: Some("d".into()),
900 icon: Some("yog:item/ruby".into()),
901 icon_svg: None,
902 sortnum: 0,
903 })
904 .add_entry(BookEntry {
905 id: "e1".into(),
906 name: "Entry".into(),
907 category: "c1".into(),
908 pages: vec![
909 text_page("plain"),
910 spotlight_page(yog_registry::ItemDef::new("yog:ruby")),
911 crafting_page("yog:r1"),
912 smelting_page("yog:r2"),
913 BookPage::Empty,
914 ],
915 icon: Some("yog:ruby".into()),
916 icon_svg: None,
917 secret: false,
918 priority: 0,
919 read_by_default: false,
920 advancement: None,
921 });
922
923 let json = book.to_json();
924 let parsed: Book = serde_json::from_str(&json)
925 .unwrap_or_else(|e| panic!("wire JSON failed to parse: {e}\njson: {json}"));
926 assert_eq!(parsed.id, "yog:test");
927 assert_eq!(parsed.entries.len(), 1);
928 assert_eq!(parsed.entries[0].pages.len(), 5);
929 match &parsed.entries[0].pages[1] {
930 BookPage::Spotlight { item, .. } => assert_eq!(item.id, "yog:ruby"),
931 p => panic!("expected spotlight, got {p:?}"),
932 }
933 match &parsed.entries[0].pages[2] {
934 BookPage::Crafting { recipe_id, .. } => assert_eq!(recipe_id, "yog:r1"),
935 p => panic!("expected crafting, got {p:?}"),
936 }
937 }
938}
939
940#[cfg(test)]
941mod localize_tests {
942 use super::*;
943
944 #[test]
945 fn localized_translates_every_text() {
946 let book = Book::new("m:b", "m.book.name")
947 .landing_text("m.book.landing")
948 .add_category(BookCategory {
949 id: "basics".into(),
950 name: "m.cat".into(),
951 ..Default::default()
952 })
953 .add_entry(BookEntry {
954 id: "e".into(),
955 name: "m.entry".into(),
956 category: "basics".into(),
957 pages: vec![text_page_titled("m.title", "m.text"), text_page("raw text")],
958 ..Default::default()
959 });
960 let out = book.localized(|k| {
961 if k.starts_with("m.") {
962 k.to_uppercase()
963 } else {
964 k.to_string()
965 }
966 });
967 assert_eq!(out.name, "M.BOOK.NAME");
968 assert_eq!(out.landing_text, "M.BOOK.LANDING");
969 assert_eq!(out.categories[0].name, "M.CAT");
970 assert_eq!(out.entries[0].name, "M.ENTRY");
971 match &out.entries[0].pages[0] {
972 BookPage::Text { text, title } => {
973 assert_eq!(text, "M.TEXT");
974 assert_eq!(title.as_deref(), Some("M.TITLE"));
975 }
976 p => panic!("{p:?}"),
977 }
978 assert!(
979 matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
980 );
981 assert_eq!(out.entries[0].id, "e");
982 }
983}