1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use yog_ids::Format;
11use yog_sync::{LazyLock, Mutex};
12
13use base64::Engine as _;
14use serde_json::Value;
15use yog_book::BookRenderer;
16use yog_formats::book::Book;
17
18use super::assets::Resources;
19use super::{gfx, images, raster};
20
21#[derive(Debug, Clone, yog_data::Schema)]
22#[data(rename_all = "camelCase")]
23pub struct BookArgs {
24 pub path: String,
25 pub text: String,
27 pub screen_w: u32,
28 pub screen_h: u32,
29 pub scale: u32,
30 #[data(default)]
31 pub action: Option<BookAction>,
32 #[data(default)]
34 pub locale: Option<String>,
35}
36
37#[derive(Debug, Clone, yog_data::Schema)]
39#[data(tag = "kind", rename_all = "camelCase")]
40pub enum BookAction {
41 Click { x: f32, y: f32 },
43 Show { entry: String, page: usize },
45 Home,
47}
48
49#[derive(Debug, yog_data::Schema)]
50#[data(rename_all = "camelCase")]
51pub struct BookRendered {
52 pub image: Option<String>,
55 pub scale: u32,
56 pub shown: Option<(String, usize)>,
58 pub action: Option<String>,
60 pub animated: bool,
62 pub locales: Vec<String>,
64 pub error: Option<String>,
65}
66
67struct Session {
68 renderer: BookRenderer,
69 given: Vec<u8>,
71}
72
73static SESSIONS: LazyLock<Mutex<HashMap<String, Session>>> =
74 LazyLock::new(|| Mutex::new(HashMap::new()));
75
76fn mod_root(path: &Path) -> Option<PathBuf> {
78 path.ancestors()
79 .find(|p| p.join("yog.toml").is_file())
80 .map(Path::to_path_buf)
81}
82
83pub(super) struct Texts {
88 pub(super) refs: HashMap<String, String>,
89 pub(super) lang: HashMap<String, String>,
90}
91
92pub(super) fn texts(mod_root: Option<&Path>, res: &Resources, locale: Option<&str>) -> Texts {
93 let lang_file = locale.unwrap_or(yog_ids::game::LOCALE);
94 let mod_id = mod_root
95 .and_then(crate::design::project::mod_id)
96 .unwrap_or_default();
97 let mut t = Texts {
98 refs: HashMap::new(),
99 lang: game_lang(res, &mod_id, lang_file),
100 };
101 let Some(root) = mod_root else { return t };
102 for (set, p) in yres_files(root, locale) {
103 add_yres(&mut t, &set, &p, &mod_id);
104 }
105 t
106}
107
108fn game_lang(res: &Resources, mod_id: &str, lang_file: &str) -> HashMap<String, String> {
111 let mut lang = HashMap::new();
112 for ns in [yog_ids::ns::MINECRAFT, mod_id] {
113 for file in [yog_ids::game::LOCALE, lang_file] {
114 if let Some(Value::Object(map)) = res
115 .read(ns, &yog_ids::lang_file(file))
116 .and_then(|b| serde_json::from_slice(&b).ok())
117 {
118 for (k, v) in map {
119 if let Value::String(v) = v {
120 lang.insert(k, v);
121 }
122 }
123 }
124 }
125 }
126 for file in [yog_ids::game::LOCALE, lang_file] {
127 if let Some((_, json)) = yog_book::LANG.iter().find(|(l, _)| *l == file)
128 && let Ok(map) = serde_json::from_str::<HashMap<String, String>>(json)
129 {
130 lang.extend(map);
131 }
132 }
133 lang
134}
135
136fn yres_files(root: &Path, locale: Option<&str>) -> Vec<(String, PathBuf)> {
138 let mut files: Vec<(String, Option<String>, PathBuf)> = Vec::new();
139 for f in std::fs::read_dir(root.join(crate::yres::RESOURCES_DIR))
140 .into_iter()
141 .flatten()
142 .flatten()
143 {
144 let p = f.path();
145 let Some((set, loc)) = p
146 .file_name()
147 .and_then(|n| n.to_str())
148 .and_then(yog_formats::yres::split_file_name)
149 else {
150 continue;
151 };
152 let (set, loc) = (set.to_string(), loc.map(str::to_string));
153 if loc.is_none() || loc.as_deref() == locale {
154 files.push((set, loc, p));
155 }
156 }
157 files.sort_by_key(|(_, loc, _)| loc.is_some());
159 files.into_iter().map(|(set, _, p)| (set, p)).collect()
160}
161
162fn add_yres(t: &mut Texts, set: &str, file: &Path, mod_id: &str) {
164 use yog_data::Value as V;
165 let Some(doc) = crate::files::read_source(file)
166 .ok()
167 .and_then(|text| crate::design::data::read_object(&text))
168 else {
169 return;
170 };
171 let text_of = |o: &V, f: &str| o.get(f).and_then(V::as_str).map(str::to_string);
172 for (k, v) in doc.as_map().into_iter().flatten() {
173 let (text, key) = match v {
174 V::Str(s) => (Some(s.clone()), None),
175 V::Map(o)
176 if o.get("type")
177 .and_then(V::as_str)
178 .is_none_or(|t| t == "text") =>
179 {
180 (text_of(v, "value"), text_of(v, "key"))
181 }
182 _ => (None, None),
183 };
184 let Some(text) = text else { continue };
185 let key = key.unwrap_or_else(|| crate::yres::default_key(set, mod_id, k));
187 t.lang.insert(key, text.clone());
188 t.refs.insert(format!("{set}.{k}"), text);
189 }
190}
191
192fn locales(mod_root: Option<&Path>) -> Vec<String> {
194 let mut out: Vec<String> = mod_root
195 .map(|r| {
196 std::fs::read_dir(r.join("resources"))
197 .into_iter()
198 .flatten()
199 .flatten()
200 .filter_map(|f| {
201 let name = f.file_name().to_string_lossy().into_owned();
202 yog_formats::yres::split_file_name(&name)?
203 .1
204 .map(str::to_string)
205 })
206 .collect()
207 })
208 .unwrap_or_default();
209 out.sort();
210 out.dedup();
211 out
212}
213
214pub(super) fn item_name(lang: &HashMap<String, String>, id: &str) -> Option<String> {
216 [yog_ids::kind::ITEM, yog_ids::kind::BLOCK]
217 .iter()
218 .find_map(|k| lang.get(&yog_ids::lang_key(k, id)).cloned())
219}
220
221static LANG: LazyLock<Mutex<HashMap<String, String>>> =
223 LazyLock::new(|| Mutex::new(HashMap::new()));
224
225fn translate(key: &str) -> Option<String> {
226 LANG.lock().get(key).cloned()
227}
228
229fn recipes(mod_root: &Path) -> Vec<(String, yog_data::Value)> {
232 let mod_id = crate::design::project::mod_id(mod_root).unwrap_or_default();
233 let mut out = Vec::new();
234 let mut stack = vec![mod_root.join(yog_ids::project::CONTENT)];
235 while let Some(dir) = stack.pop() {
236 for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
237 let p = e.path();
238 if p.is_dir() {
239 stack.push(p);
240 continue;
241 }
242 if !Format::Recipe.is(&p) {
243 continue;
244 }
245 let Some(mut v) = crate::files::read_source(&p)
246 .ok()
247 .and_then(|t| crate::design::data::read_object(&t))
248 else {
249 continue;
250 };
251 crate::design::data::runtime_ids(&mut v);
252 let stem = p
253 .file_stem()
254 .map(|s| s.to_string_lossy().into_owned())
255 .unwrap_or_default();
256 let id = match v
257 .get(yog_ids::content_field::ID)
258 .and_then(yog_data::Value::as_str)
259 {
260 Some(id) if id.contains(':') => id.to_string(),
261 Some(id) => format!("{mod_id}:{id}"),
262 None => format!("{mod_id}:{stem}"),
263 };
264 out.push((id, v));
265 }
266 }
267 out
268}
269
270fn humanize(id: &str) -> String {
272 let path = id.rsplit([':', '/']).next().unwrap_or(id);
273 path.split('_')
274 .filter(|w| !w.is_empty())
275 .map(|w| {
276 let mut c = w.chars();
277 c.next()
278 .map(|f| f.to_uppercase().collect::<String>() + c.as_str())
279 .unwrap_or_default()
280 })
281 .collect::<Vec<_>>()
282 .join(" ")
283}
284
285struct Reader<'a> {
290 texts: &'a Texts,
291 missing: Vec<String>,
292}
293
294impl yog_data::Resolve for Reader<'_> {
295 fn text(&mut self, t: &yog_data::Text, _: &yog_data::Path) -> Result<String, String> {
296 Ok(match t {
297 yog_data::Text::Literal(s) => s.clone(),
298 yog_data::Text::NameOf(r) => {
299 let id = yog_formats::book::refs::bare(&r.id);
300 let name = match r.kind.as_str() {
301 yog_ids::kind::ENTITY => self
302 .texts
303 .lang
304 .get(&yog_ids::lang_key(yog_ids::kind::ENTITY, id))
305 .cloned(),
306 _ => item_name(&self.texts.lang, id),
307 };
308 name.unwrap_or_else(|| humanize(id))
309 }
310 yog_data::Text::Lang(k) => self.texts.lang.get(k).cloned().unwrap_or_else(|| k.clone()),
311 yog_data::Text::Res(r) => {
312 self.texts.refs.get(&r.name()).cloned().unwrap_or_else(|| {
313 self.missing.push(r.to_string());
314 r.to_string()
315 })
316 }
317 })
318 }
319
320 fn words(&mut self, r: &yog_data::Res) -> Option<String> {
321 self.texts.refs.get(&r.name()).cloned()
322 }
323}
324
325fn repair(v: &mut yog_data::Value, path: &yog_data::Path, message: &str) -> bool {
328 use yog_data::{Seg, Value};
329 let segs = path.segments();
330 if let Some(field) = message
332 .strip_prefix("missing `")
333 .and_then(|r| r.split('`').next())
334 && let Some(Value::Map(m)) = v.at_mut(segs)
335 && m.get(field).is_none()
336 {
337 m.insert(field.to_string(), Value::from(""));
338 return true;
339 }
340 if let Some(i) = segs
342 .iter()
343 .position(|s| matches!(s, Seg::Key(k) if k == "pages"))
344 && let Some(Seg::Index(page)) = segs.get(i + 1)
345 {
346 return match v.at_mut(&segs[..=i]) {
347 Some(Value::List(pages)) if *page < pages.len() => {
348 pages.remove(*page);
349 true
350 }
351 _ => false,
352 };
353 }
354 let Some((last, parents)) = segs.split_last() else {
356 return false;
357 };
358 match (last, v.at_mut(parents)) {
359 (Seg::Key(k), Some(Value::Map(m))) => m.remove(k).is_some(),
360 (Seg::Index(n), Some(Value::List(l))) if *n < l.len() => {
361 l.remove(*n);
362 true
363 }
364 _ => false,
365 }
366}
367
368pub fn parse(
371 text: &str,
372 mod_root: Option<&Path>,
373 locale: Option<&str>,
374) -> Result<(Book, Option<String>), String> {
375 let doc = yog_data::text::parse(text).map_err(|e| format!("{}: {}", e.at, e.message))?;
376 let res = Resources::for_mod(mod_root);
377 let texts = texts(mod_root, &res, locale);
378 let mut value = doc.value.clone();
379 let mut first: Option<String> = None;
380 let mut reader = Reader {
381 texts: &texts,
382 missing: Vec::new(),
383 };
384 let mut book = None;
385 for _ in 0..32 {
386 match yog_data::from_value_with::<Book>(&value, &mut reader) {
387 Ok(read) => {
388 book = Some(read.value);
389 break;
390 }
391 Err(problems) => {
392 if first.is_none()
393 && let Some(p) = problems.first()
394 {
395 let at = doc.locate(text, &p.path);
396 first = Some(if p.path.is_root() {
397 format!("{at}: {}", p.message)
398 } else {
399 format!("{at}: {}: {}", p.path, p.message)
400 });
401 }
402 let mut changed = false;
404 for p in &problems {
405 changed |= repair(&mut value, &p.path, &p.message);
406 }
407 if !changed {
408 break;
409 }
410 }
411 }
412 }
413 let mut book = book.ok_or_else(|| first.clone().unwrap_or_else(|| "not a book".into()))?;
414 book.i18n = false;
416 book.layouts = mod_root
418 .map(|r| {
419 crate::design::mod_ui::recipe_layouts(r)
420 .into_iter()
421 .map(|(_, l)| l)
422 .collect()
423 })
424 .unwrap_or_default();
425 book.page_types = mod_root
426 .map(crate::design::mod_ui::page_types)
427 .unwrap_or_default();
428 let first = first.or_else(|| {
429 reader
430 .missing
431 .first()
432 .map(|m| format!("{m}: no such text in resources/*.yres"))
433 });
434 let mut lang = LANG.lock();
435 *lang = texts.lang;
436 Ok((book, first))
437}
438
439pub fn render(args: &BookArgs) -> BookRendered {
440 let root = mod_root(Path::new(&args.path));
441 let locales = locales(root.as_deref());
442 let locale = args.locale.as_deref().filter(|l| !l.is_empty());
443 let fail = |error: String| BookRendered {
444 image: None,
445 scale: args.scale,
446 shown: None,
447 action: None,
448 animated: false,
449 locales: locales.clone(),
450 error: Some(error),
451 };
452 let (book, problem) = match parse(&args.text, root.as_deref(), locale) {
453 Ok(b) => b,
454 Err(e) => return fail(e),
455 };
456 render_book(args, book, problem, root.as_deref(), locales)
457}
458
459static BOOK_MOD: yog_sync::Mutex<Option<PathBuf>> = yog_sync::Mutex::new(None);
462
463fn book_tag_items(tag: &str) -> Vec<String> {
465 let root = BOOK_MOD.lock().clone();
466 root.map(|r| super::tags::item_tag(&r.join(yog_ids::file::MANIFEST), tag))
467 .unwrap_or_default()
468}
469
470fn render_book(
471 args: &BookArgs,
472 book: yog_formats::book::Book,
473 problem: Option<String>,
474 root: Option<&Path>,
475 locales: Vec<String>,
476) -> BookRendered {
477 yog_book::set_tag_items(book_tag_items);
478 let mut m = BOOK_MOD.lock();
479 *m = root.map(Path::to_path_buf);
480 let (sw, sh) = (args.screen_w.clamp(1, 4096), args.screen_h.clamp(1, 4096));
481 let fail = |error: String| BookRendered {
482 image: None,
483 scale: args.scale,
484 shown: None,
485 action: None,
486 animated: false,
487 locales: locales.clone(),
488 error: Some(error),
489 };
490 let given = yog_data::to_bytes(&book);
491
492 let mut sessions = SESSIONS.lock();
493 let session = sessions.entry(args.path.clone()).or_insert_with(|| {
494 let mut renderer = BookRenderer::new(book.clone());
495 renderer.set_translator(translate);
496 for (id, recipe) in root.map(recipes).unwrap_or_default() {
497 renderer.add_recipe(id, &recipe);
498 }
499 Session {
500 renderer,
501 given: given.clone(),
502 }
503 });
504 if session.given != given {
505 session.renderer.set_book(book);
506 session.given = given;
507 }
508 let r = &mut session.renderer;
509
510 gfx::record(sw as i32, sh as i32, |ctx| {
512 r.render(ctx, sw as f32, sh as f32)
513 });
514 let action = match &args.action {
515 Some(BookAction::Click { x, y }) => r.click(*x, *y),
516 Some(BookAction::Show { entry, page }) => {
517 r.show(entry, *page);
518 None
519 }
520 Some(BookAction::Home) => {
521 r.handle_event("home");
522 None
523 }
524 None => None,
525 };
526
527 images::set_document(root);
528 let ops = gfx::record(sw as i32, sh as i32, |ctx| {
529 r.render(ctx, sw as f32, sh as f32)
530 });
531 let res = Resources::for_mod(root);
532 let scale = raster::fit_scale(sw, sh, args.scale);
533 let canvas = raster::rasterize(&ops, sw, sh, scale, &res);
534 match canvas.png() {
535 Ok(png) => BookRendered {
536 image: Some(format!(
537 "data:image/png;base64,{}",
538 base64::engine::general_purpose::STANDARD.encode(png)
539 )),
540 scale,
541 shown: r.shown(),
542 action,
543 animated: r.animated(),
544 locales,
545 error: problem,
546 },
547 Err(e) => fail(e),
548 }
549}
550
551fn sample(p: &yog_formats::book::page_type::PageProperty) -> yog_data::Value {
554 use yog_data::Value;
555 use yog_formats::book::page_type::PropKind;
556 if let Some(d) = &p.default {
557 return d.clone();
558 }
559 match p.kind {
560 PropKind::Text => Value::Str(format!("‹{}›", p.name)),
561 PropKind::BookText => Value::Str(format!(
562 "‹{}› — the page's text, as a book wraps it.",
563 p.name
564 )),
565 PropKind::Number => Value::Int(1),
566 PropKind::Bool => Value::Bool(true),
567 PropKind::Item => yog_ids::game::BOOK.into(),
568 PropKind::Image => yog_ids::join(yog_ids::image_scheme::ITEM, yog_ids::game::BOOK).into(),
569 PropKind::Data => Value::Null,
570 }
571}
572
573pub fn page_preview(args: &BookArgs) -> BookRendered {
577 let root = mod_root(Path::new(&args.path));
578 let locales = locales(root.as_deref());
579 let fail = |error: String| BookRendered {
580 image: None,
581 scale: args.scale,
582 shown: None,
583 action: None,
584 animated: false,
585 locales: locales.clone(),
586 error: Some(error),
587 };
588 let ns = root
589 .as_deref()
590 .map(crate::design::mod_ui::ns)
591 .unwrap_or_else(|| yog_ids::ns::MINECRAFT.into());
592 let kind = match yog_formats::yui::parse(&args.text)
593 .and_then(|el| yog_formats::book::page_type::compile(&el, &ns))
594 {
595 Ok(k) => k,
596 Err(e) => return fail(e),
597 };
598 use yog_data::{Map, Value};
599 let mut page = Map::from_iter([("type", Value::from(kind.id.as_str()))]);
600 for p in &kind.properties {
601 let v = sample(p);
602 if !v.is_null() {
603 page.insert(p.name.as_str(), v);
604 }
605 }
606 let entry = format!("{ns}:page_preview");
607 let named = |extra: Vec<(&str, Value)>| -> Value {
608 let mut m = Map::from_iter([
609 ("id", Value::from(entry.as_str())),
610 ("name", kind.id.as_str().into()),
611 ]);
612 for (k, v) in extra {
613 m.insert(k, v);
614 }
615 m.into()
616 };
617 let book = named(vec![
618 ("categories", Value::List(vec![named(vec![])])),
619 (
620 "entries",
621 Value::List(vec![named(vec![
622 (
623 "category",
624 Value::Ref(yog_data::Ref {
625 kind: yog_ids::kind::CATEGORY.into(),
626 id: entry.clone(),
627 }),
628 ),
629 ("pages", Value::List(vec![page.into()])),
630 ])]),
631 ),
632 ]);
633 let text = yog_data::text::to_string(&book);
634 let locale = args.locale.as_deref().filter(|l| !l.is_empty());
635 let (mut book, problem) = match parse(&text, root.as_deref(), locale) {
636 Ok(b) => b,
637 Err(e) => return fail(e),
638 };
639 book.page_types.retain(|t| t.id != kind.id);
640 book.page_types.push(kind);
641 let args = BookArgs {
642 action: Some(BookAction::Show { entry, page: 0 }),
643 ..args.clone()
644 };
645 render_book(&args, book, problem, root.as_deref(), locales)
646}
647
648pub fn close(path: &str) {
650 SESSIONS.lock().remove(path);
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
658 fn resolves_references_to_their_texts() {
659 let mut texts = Texts {
660 refs: HashMap::new(),
661 lang: HashMap::new(),
662 };
663 texts
664 .refs
665 .insert("Book.title".to_string(), "Guide".to_string());
666 texts
667 .lang
668 .insert("item.mymod.sapphire".to_string(), "Сапфир".to_string());
669 let v = yog_data::text::parse_value(
670 r#"{
671 name: "@Book.title", subtitle: "@item:mymod:ruby_block", landing_text: "@@x",
672 categories: [{ id: "m:c", name: "@item:mymod:sapphire", icon: "@item:mymod:sapphire" }],
673 entries: [{ id: "m:e", name: "@Book.nope", category: "@category:m:c", pages: [] }],
674 id: "m:b",
675 }"#,
676 )
677 .unwrap();
678 let mut r = Reader {
679 texts: &texts,
680 missing: Vec::new(),
681 };
682 let b = yog_data::from_value_with::<Book>(&v, &mut r)
683 .unwrap_or_else(|p| panic!("{p:?}"))
684 .value;
685 assert_eq!(b.name, "Guide");
686 assert_eq!(
687 b.subtitle.as_deref(),
688 Some("Ruby Block"),
689 "no translation: made from the id"
690 );
691 assert_eq!(b.categories[0].name, "Сапфир", "a name in a text");
692 assert_eq!(
693 b.categories[0].icon.as_deref(),
694 Some("mymod:sapphire"),
695 "an id in an id field"
696 );
697 assert_eq!(b.entries[0].category, "m:c");
698 assert_eq!(b.landing_text, "@x");
699 assert_eq!(r.missing, ["@Book.nope"]);
700 }
701
702 #[test]
703 fn draws_what_it_can_of_a_broken_book() {
704 let text = r#"{ id: "t:guide", name: "Guide",
705 categories: [{ id: "t:c", name: "C" }],
706 entries: [{ id: "t:e", category: "@category:t:c", pages: [
707 { type: "spotlight", item: "" },
708 { type: "text", text: "kept" },
709 ] }] }"#;
710 let (book, problem) = parse(text, None, None).unwrap();
711 let problem = problem.unwrap();
712 assert!(
713 problem.starts_with("3:23: entries[0]: missing `name`"),
714 "{problem}"
715 );
716 assert_eq!(
717 book.entries[0].pages.len(),
718 1,
719 "the broken page is left out"
720 );
721 }
722
723 #[test]
724 fn draws_and_clicks_through_a_book() {
725 let text = r#"{ "id": "t:guide", "name": "Guide",
726 "categories": [{ "id": "t:c", "name": "Basics" }],
727 "entries": [{ "id": "t:e", "name": "First", "category": "@category:t:c",
728 "pages": [{ "type": "text", "text": "one" }, { "type": "text", "text": "two" }] }] }"#;
729 let args = |action| BookArgs {
730 path: "/tmp/test.ybook".into(),
731 text: text.into(),
732 screen_w: 400,
733 screen_h: 240,
734 scale: 1,
735 action,
736 locale: None,
737 };
738 let r = render(&args(None));
739 assert!(r.error.is_none() && r.image.is_some(), "{:?}", r.error);
740 assert_eq!(r.shown, None);
741 let r = render(&args(Some(BookAction::Show {
742 entry: "t:e".into(),
743 page: 1,
744 })));
745 assert_eq!(r.shown, Some(("t:e".into(), 1)));
746 let r = render(&args(Some(BookAction::Home)));
747 assert_eq!(r.shown, None);
748 close("/tmp/test.ybook");
749 }
750
751 #[test]
752 fn a_book_page_component_is_drawn_as_the_book_shows_it() {
753 let dir =
754 std::env::temp_dir().join(format!("yog-idle-page-preview-{}", std::process::id()));
755 let _ = std::fs::remove_dir_all(&dir);
756 std::fs::create_dir_all(dir.join("src/ui")).unwrap();
757 std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
758 let yui = r#"<Component name="Note" book_page="note">
759 <Property name="title" type="text"/>
760 <Property name="body" type="book_text" default="Hello"/>
761 <BookHeader text="{x:Bind title}"/>
762 <BookText text="{x:Bind body}"/>
763 <Drawn drawer="mymod:thing" h="32" data="{x:Bind title}"/>
764 </Component>"#;
765 let args = BookArgs {
766 path: dir.join("src/ui/note.yui").to_string_lossy().into_owned(),
767 text: yui.into(),
768 screen_w: 480,
769 screen_h: 270,
770 scale: 1,
771 action: None,
772 locale: None,
773 };
774 let r = page_preview(&args);
775 assert!(r.error.is_none(), "{:?}", r.error);
776 assert!(r.image.is_some());
777 assert_eq!(r.shown, Some(("mymod:page_preview".to_string(), 0)));
778 let _ = std::fs::remove_dir_all(&dir);
779 }
780}