1pub mod layout;
9pub mod page_type;
10pub mod refs;
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15use yog_data::Schema;
16use yog_data::kinds::{Block, Category, Entity, Entry, Item, Recipe};
17use yog_data::with::{List, Opt, RefTo, Values};
18
19use refs::{BookText, Icon};
20
21
22mod value_json {
24 use serde::{Deserialize, Deserializer, Serialize, Serializer};
25
26 pub fn serialize<S: Serializer>(v: &yog_data::Value, s: S) -> Result<S::Ok, S::Error> {
27 yog_data::json::to_json(v).serialize(s)
28 }
29
30 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<yog_data::Value, D::Error> {
31 Ok(yog_data::json::from_json(&serde_json::Value::deserialize(d)?))
32 }
33}
34
35pub fn multiblock_blocks(
38 pattern: &[Vec<String>],
39 mapping: &BTreeMap<String, String>,
40) -> Vec<((i32, i32, i32), String)> {
41 let mut out = Vec::new();
42 let layers = pattern.len() as i32;
43 for (li, layer) in pattern.iter().enumerate() {
44 for (z, row) in layer.iter().enumerate() {
45 for (x, ch) in row.chars().enumerate() {
46 if ch == ' ' {
47 continue;
48 }
49 if let Some(state) = mapping.get(&ch.to_string()) {
50 out.push(((x as i32, layers - 1 - li as i32, z as i32), state.clone()));
51 }
52 }
53 }
54 }
55 out
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
62#[serde(tag = "type", rename_all = "snake_case")]
63#[data(tag = "type")]
64pub enum BookPage {
65 Text {
69 #[data(with = "BookText")]
70 text: String,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 #[data(with = "Opt<BookText>")]
73 title: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 font: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 font_size: Option<f32>,
80 },
81 Spotlight {
83 #[data(with = "RefTo<(Item, Block)>")]
84 item: String,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 #[data(with = "Opt<BookText>")]
87 title: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 #[data(with = "Opt<BookText>")]
90 text: Option<String>,
91 },
92 Recipe {
94 #[data(with = "RefTo<Recipe>")]
95 recipe: String,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 #[data(with = "Opt<BookText>")]
98 text: Option<String>,
99 },
100 Image {
102 texture: String,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 #[data(with = "Opt<BookText>")]
106 title: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 #[data(with = "Opt<BookText>")]
109 text: Option<String>,
110 },
111 Entity {
113 #[data(with = "RefTo<Entity>")]
114 entity: String,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 #[data(with = "Opt<BookText>")]
117 name: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 #[data(with = "Opt<BookText>")]
120 text: Option<String>,
121 },
122 Multiblock {
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 #[data(with = "Opt<BookText>")]
128 name: Option<String>,
129 pattern: Vec<Vec<String>>,
131 #[data(with = "Values<RefTo<Block>>")]
133 mapping: BTreeMap<String, String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 #[data(with = "Opt<BookText>")]
136 text: Option<String>,
137 },
138 Relations {
140 #[data(with = "List<RefTo<Entry>>")]
141 entries: Vec<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 #[data(with = "Opt<BookText>")]
144 text: Option<String>,
145 },
146 Drawn {
151 drawer: String,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 #[data(with = "Opt<BookText>")]
155 title: Option<String>,
156 #[serde(default)]
158 #[data(default, range = "0..=150")]
159 height: f32,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 #[data(with = "Opt<BookText>")]
163 caption: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 #[data(with = "Opt<BookText>")]
166 text: Option<String>,
167 #[serde(default, with = "value_json")]
169 #[data(default)]
170 data: yog_data::Value,
171 },
172 Empty,
174 #[serde(skip)]
177 #[data(other)]
178 Custom(page_type::CustomPage),
179}
180
181#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema)]
185#[serde(default)]
186pub struct BookCategory {
187 pub id: String,
188 #[data(with = "BookText")]
189 pub name: String,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 #[data(with = "Opt<BookText>")]
192 pub description: Option<String>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 #[data(with = "Opt<Icon>")]
196 pub icon: Option<String>,
197 #[data(default)]
199 pub sortnum: i32,
200}
201
202#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema)]
204#[serde(default)]
205pub struct BookEntry {
206 pub id: String,
207 #[data(with = "BookText")]
208 pub name: String,
209 #[data(with = "RefTo<Category>")]
210 pub category: String,
211 #[data(default)]
212 pub pages: Vec<BookPage>,
213 #[serde(skip_serializing_if = "Option::is_none")]
215 #[data(with = "Opt<Icon>")]
216 pub icon: Option<String>,
217 #[data(default)]
219 pub priority: i32,
220 #[data(default)]
222 pub secret: bool,
223 #[data(default)]
225 pub read_by_default: bool,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 #[data(default)]
230 pub requires: Vec<String>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub advancement: Option<String>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
240#[serde(default)]
241pub struct Book {
242 pub id: String,
244 #[data(with = "BookText")]
245 pub name: String,
246 #[serde(skip_serializing_if = "Option::is_none")]
248 #[data(with = "Opt<BookText>")]
249 pub subtitle: Option<String>,
250 #[data(default)]
253 pub version: u32,
254 #[data(with = "BookText", default)]
255 pub landing_text: String,
256 #[data(default = "default_nameplate_color")]
258 pub nameplate_color: String,
259 #[data(default = "default_accent")]
261 pub accent: String,
262 #[data(default = "default_book_texture")]
264 pub book_texture: String,
265 #[data(default)]
266 pub categories: Vec<BookCategory>,
267 #[data(default)]
268 pub entries: Vec<BookEntry>,
269 #[data(default)]
272 pub i18n: bool,
273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 #[data(default)]
276 pub layouts: Vec<layout::RecipeLayout>,
277 #[serde(skip)]
280 #[data(default)]
281 pub page_types: Vec<page_type::PageType>,
282}
283
284fn default_nameplate_color() -> String {
285 "FFDD98".into()
286}
287
288fn default_accent() -> String {
289 "#8d6acc".into()
290}
291
292fn default_book_texture() -> String {
293 yog_ids::yog::BOOK_TEXTURE.into()
294}
295
296impl Default for Book {
297 fn default() -> Self {
298 Book {
299 id: String::new(),
300 name: String::new(),
301 subtitle: None,
302 version: 0,
303 landing_text: String::new(),
304 nameplate_color: default_nameplate_color(),
305 accent: default_accent(),
306 book_texture: default_book_texture(),
307 categories: Vec::new(),
308 entries: Vec::new(),
309 i18n: false,
310 layouts: Vec::new(),
311 page_types: Vec::new(),
312 }
313 }
314}
315
316impl Book {
317 pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
322 let opt = |o: &mut Option<String>| {
323 if let Some(t) = o {
324 *t = tr(t);
325 }
326 };
327 let mut b = self.clone();
328 let types = std::mem::take(&mut b.page_types);
329 b.name = tr(&b.name);
330 b.landing_text = tr(&b.landing_text);
331 opt(&mut b.subtitle);
332 for c in &mut b.categories {
333 c.name = tr(&c.name);
334 opt(&mut c.description);
335 }
336 for e in &mut b.entries {
337 e.name = tr(&e.name);
338 for p in &mut e.pages {
339 match p {
340 BookPage::Text { text, title, .. } => {
341 *text = tr(text);
342 opt(title);
343 }
344 BookPage::Spotlight { title, text, .. }
345 | BookPage::Image { title, text, .. } => {
346 opt(title);
347 opt(text);
348 }
349 BookPage::Entity { name, text, .. }
350 | BookPage::Multiblock { name, text, .. } => {
351 opt(name);
352 opt(text);
353 }
354 BookPage::Recipe { text, .. } | BookPage::Relations { text, .. } => opt(text),
355 BookPage::Drawn { title, caption, text, .. } => {
356 opt(title);
357 opt(caption);
358 opt(text);
359 }
360 BookPage::Empty => {}
361 BookPage::Custom(c) => {
363 let Some(t) = types.iter().find(|t| t.id == c.kind) else { continue };
364 for prop in t.properties.iter().filter(|p| matches!(p.kind, page_type::PropKind::Text | page_type::PropKind::BookText)) {
365 if let Some(yog_data::Value::Str(s)) = c.fields.get_mut(&prop.name) {
366 *s = tr(s);
367 }
368 }
369 }
370 }
371 }
372 }
373 b.page_types = types;
374 b
375 }
376
377 pub fn to_json(&self) -> String {
379 serde_json::to_string(self).unwrap_or_default()
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 fn text(t: &str) -> BookPage {
388 BookPage::Text {
389 text: t.into(),
390 title: None,
391 font: None,
392 font_size: None,
393 }
394 }
395
396 #[test]
397 fn books_round_trip_through_their_json() {
398 let book = Book {
399 id: "yog:test".into(),
400 name: "Test Book".into(),
401 version: 1,
402 categories: vec![BookCategory {
403 id: "yog:c1".into(),
404 name: "Cat".into(),
405 icon: Some("yog:ruby".into()),
406 ..Default::default()
407 }],
408 entries: vec![BookEntry {
409 id: "yog:e1".into(),
410 name: "Entry".into(),
411 category: "yog:c1".into(),
412 pages: vec![
413 text("plain"),
414 BookPage::Spotlight {
415 item: "yog:ruby".into(),
416 title: None,
417 text: None,
418 },
419 BookPage::Recipe {
420 recipe: "yog:r1".into(),
421 text: None,
422 },
423 BookPage::Empty,
424 ],
425 ..Default::default()
426 }],
427 ..Default::default()
428 };
429 let parsed: Book = serde_json::from_str(&book.to_json()).expect("its JSON parses");
430 assert_eq!(parsed.version, 1);
431 assert_eq!(parsed.entries[0].pages.len(), 4);
432 assert!(
433 matches!(&parsed.entries[0].pages[1], BookPage::Spotlight { item, .. } if item == "yog:ruby")
434 );
435 assert!(
436 matches!(&parsed.entries[0].pages[2], BookPage::Recipe { recipe, .. } if recipe == "yog:r1")
437 );
438 }
439
440 #[test]
441 fn localized_translates_every_text() {
442 let book = Book {
443 id: "m:b".into(),
444 name: "m.book.name".into(),
445 landing_text: "m.book.landing".into(),
446 categories: vec![BookCategory {
447 id: "m:basics".into(),
448 name: "m.cat".into(),
449 ..Default::default()
450 }],
451 entries: vec![BookEntry {
452 id: "m:e".into(),
453 name: "m.entry".into(),
454 category: "m:basics".into(),
455 pages: vec![
456 BookPage::Text {
457 text: "m.text".into(),
458 title: Some("m.title".into()),
459 font: None,
460 font_size: None,
461 },
462 text("raw text"),
463 ],
464 ..Default::default()
465 }],
466 ..Default::default()
467 };
468 let out = book.localized(|k| {
469 if k.starts_with("m.") {
470 k.to_uppercase()
471 } else {
472 k.to_string()
473 }
474 });
475 assert_eq!(out.name, "M.BOOK.NAME");
476 assert_eq!(out.landing_text, "M.BOOK.LANDING");
477 assert_eq!(out.categories[0].name, "M.CAT");
478 assert_eq!(out.entries[0].name, "M.ENTRY");
479 assert!(
480 matches!(&out.entries[0].pages[0], BookPage::Text { text, title, .. } if text == "M.TEXT" && title.as_deref() == Some("M.TITLE"))
481 );
482 assert!(
483 matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
484 );
485 }
486}