1use yog_ids::Format;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::{LazyLock, Mutex};
12
13use base64::Engine as _;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use yog_book::BookRenderer;
17use yog_formats::book::Book;
18
19use super::assets::Resources;
20use super::{gfx, images, raster};
21
22#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct BookArgs {
25 pub path: String,
26 pub text: String,
28 pub screen_w: u32,
29 pub screen_h: u32,
30 pub scale: u32,
31 #[serde(default)]
32 pub action: Option<BookAction>,
33 #[serde(default)]
35 pub locale: Option<String>,
36}
37
38#[derive(Debug, Clone, Deserialize)]
40#[serde(tag = "kind", rename_all = "camelCase")]
41pub enum BookAction {
42 Click { x: f32, y: f32 },
44 Show { entry: String, page: usize },
46 Home,
48}
49
50#[derive(Debug, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub struct BookRendered {
53 pub image: Option<String>,
56 pub scale: u32,
57 pub shown: Option<(String, usize)>,
59 pub action: Option<String>,
61 pub animated: bool,
63 pub locales: Vec<String>,
65 pub error: Option<String>,
66}
67
68struct Session {
69 renderer: BookRenderer,
70 json: String,
72}
73
74static SESSIONS: LazyLock<Mutex<HashMap<String, Session>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
75
76fn mod_root(path: &Path) -> Option<PathBuf> {
78 path.ancestors().find(|p| p.join("yog.toml").is_file()).map(Path::to_path_buf)
79}
80
81struct Texts {
86 refs: HashMap<String, String>,
87 lang: HashMap<String, String>,
88}
89
90fn texts(mod_root: Option<&Path>, res: &Resources, locale: Option<&str>) -> Texts {
91 let mut t = Texts { refs: HashMap::new(), lang: HashMap::new() };
92 let lang_file = locale.unwrap_or(yog_ids::game::LOCALE);
94 let mod_id = mod_root.and_then(|r| crate::design::project::mod_id(r)).unwrap_or_default();
95 for ns in [yog_ids::ns::MINECRAFT, mod_id.as_str()] {
96 for file in [yog_ids::game::LOCALE, lang_file] {
97 if let Some(Value::Object(map)) = res.read(ns, &yog_ids::lang_file(file)).and_then(|b| serde_json::from_slice(&b).ok()) {
98 for (k, v) in map {
99 if let Value::String(v) = v {
100 t.lang.insert(k, v);
101 }
102 }
103 }
104 }
105 }
106 for file in [yog_ids::game::LOCALE, lang_file] {
108 if let Some((_, json)) = yog_book::LANG.iter().find(|(l, _)| *l == file)
109 && let Ok(map) = serde_json::from_str::<HashMap<String, String>>(json)
110 {
111 t.lang.extend(map);
112 }
113 }
114 let Some(root) = mod_root else { return t };
115 let mut files: Vec<(String, Option<String>, PathBuf)> = Vec::new();
116 for f in std::fs::read_dir(root.join("resources")).into_iter().flatten().flatten() {
117 let p = f.path();
118 let Some((set, loc)) = p.file_name().and_then(|n| n.to_str()).and_then(yog_formats::yres::split_file_name) else { continue };
119 let (set, loc) = (set.to_string(), loc.map(str::to_string));
120 if loc.is_none() || loc.as_deref() == locale {
121 files.push((set, loc, p));
122 }
123 }
124 files.sort_by_key(|(_, loc, _)| loc.is_some());
126 for (set, _, p) in files {
127 let Some(Value::Object(map)) = std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str(&t).ok()) else {
128 continue;
129 };
130 for (k, v) in map {
131 let (text, key) = match &v {
132 Value::String(s) => (Some(s.clone()), None),
133 Value::Object(o) if o.get("type").is_none_or(|t| t == "text") => (
134 o.get("value").and_then(Value::as_str).map(str::to_string),
135 o.get("key").and_then(Value::as_str).map(str::to_string),
136 ),
137 _ => (None, None),
138 };
139 let Some(text) = text else { continue };
140 let key = key.unwrap_or_else(|| match set.as_str() {
142 "Items" => format!("item.{mod_id}.{k}"),
143 "Blocks" => format!("block.{mod_id}.{k}"),
144 "Entities" => format!("entity.{mod_id}.{k}"),
145 _ => format!("{mod_id}.{k}"),
146 });
147 t.lang.insert(key, text.clone());
148 t.refs.insert(format!("{set}.{k}"), text);
149 }
150 }
151 t
152}
153
154fn locales(mod_root: Option<&Path>) -> Vec<String> {
156 let mut out: Vec<String> = mod_root
157 .map(|r| {
158 std::fs::read_dir(r.join("resources"))
159 .into_iter()
160 .flatten()
161 .flatten()
162 .filter_map(|f| {
163 let name = f.file_name().to_string_lossy().into_owned();
164 yog_formats::yres::split_file_name(&name)?.1.map(str::to_string)
165 })
166 .collect()
167 })
168 .unwrap_or_default();
169 out.sort();
170 out.dedup();
171 out
172}
173
174fn item_name(lang: &HashMap<String, String>, id: &str) -> Option<String> {
176 [yog_ids::kind::ITEM, yog_ids::kind::BLOCK].iter().find_map(|k| lang.get(&yog_ids::lang_key(k, id)).cloned())
177}
178
179static LANG: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
181
182fn translate(key: &str) -> Option<String> {
183 LANG.lock().ok()?.get(key).cloned()
184}
185
186fn recipes(mod_root: &Path) -> Vec<(String, yog_data::Value)> {
189 let mod_id = crate::design::project::mod_id(mod_root).unwrap_or_default();
190 let mut out = Vec::new();
191 let mut stack = vec![mod_root.join("content")];
192 while let Some(dir) = stack.pop() {
193 for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
194 let p = e.path();
195 if p.is_dir() {
196 stack.push(p);
197 continue;
198 }
199 if !Format::Recipe.is(&p) {
200 continue;
201 }
202 let Some(mut v) = std::fs::read_to_string(&p).ok().and_then(|t| crate::design::data::read_object(&t)).map(Value::Object) else {
203 continue;
204 };
205 crate::design::data::runtime_ids(&mut v);
206 let stem = p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
207 let id = match v.get("id").and_then(Value::as_str) {
208 Some(id) if id.contains(':') => id.to_string(),
209 Some(id) => format!("{mod_id}:{id}"),
210 None => format!("{mod_id}:{stem}"),
211 };
212 out.push((id, crate::design::data::from_json(&v)));
213 }
214 }
215 out
216}
217
218fn humanize(id: &str) -> String {
220 let path = id.rsplit([':', '/']).next().unwrap_or(id);
221 path.split('_')
222 .filter(|w| !w.is_empty())
223 .map(|w| {
224 let mut c = w.chars();
225 c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
226 })
227 .collect::<Vec<_>>()
228 .join(" ")
229}
230
231struct Reader<'a> {
236 texts: &'a Texts,
237 missing: Vec<String>,
238}
239
240impl yog_data::Resolve for Reader<'_> {
241 fn text(&mut self, t: &yog_data::Text, _: &yog_data::Path) -> Result<String, String> {
242 Ok(match t {
243 yog_data::Text::Literal(s) => s.clone(),
244 yog_data::Text::NameOf(r) => {
245 let id = yog_formats::book::refs::bare(&r.id);
246 let name = match r.kind.as_str() {
247 yog_ids::kind::ENTITY => self.texts.lang.get(&yog_ids::lang_key(yog_ids::kind::ENTITY, id)).cloned(),
248 _ => item_name(&self.texts.lang, id),
249 };
250 name.unwrap_or_else(|| humanize(id))
251 }
252 yog_data::Text::Lang(k) => self.texts.lang.get(k).cloned().unwrap_or_else(|| k.clone()),
253 yog_data::Text::Res(r) => {
254 let key = format!("{}.{}", r.set, r.key);
255 self.texts.refs.get(&key).cloned().unwrap_or_else(|| {
256 self.missing.push(format!("@{key}"));
257 format!("@{key}")
258 })
259 }
260 })
261 }
262
263 fn words(&mut self, r: &yog_data::Res) -> Option<String> {
264 self.texts.refs.get(&format!("{}.{}", r.set, r.key)).cloned()
265 }
266}
267
268fn repair(v: &mut yog_data::Value, path: &yog_data::Path, message: &str) -> bool {
271 use yog_data::{Seg, Value};
272 let segs = path.segments();
273 if let Some(field) = message.strip_prefix("missing `").and_then(|r| r.split('`').next()) {
275 let mut at = &mut *v;
276 for seg in segs {
277 at = match (seg, at) {
278 (Seg::Key(k), Value::Map(m)) => match m.get_mut(k) {
279 Some(x) => x,
280 None => return false,
281 },
282 (Seg::Index(n), Value::List(l)) => match l.get_mut(*n) {
283 Some(x) => x,
284 None => return false,
285 },
286 _ => return false,
287 };
288 }
289 if let Value::Map(m) = at
290 && m.get(field).is_none()
291 {
292 m.insert(field.to_string(), Value::from(""));
293 return true;
294 }
295 }
296 if let Some(i) = segs.iter().position(|s| matches!(s, Seg::Key(k) if k == "pages"))
297 && let Some(Seg::Index(page)) = segs.get(i + 1)
298 {
299 let mut at = &mut *v;
300 for seg in &segs[..=i] {
301 at = match (seg, at) {
302 (Seg::Key(k), Value::Map(m)) => match m.get_mut(k) {
303 Some(x) => x,
304 None => return false,
305 },
306 (Seg::Index(n), Value::List(l)) => match l.get_mut(*n) {
307 Some(x) => x,
308 None => return false,
309 },
310 _ => return false,
311 };
312 }
313 if let Value::List(pages) = at
314 && *page < pages.len()
315 {
316 pages.remove(*page);
317 return true;
318 }
319 return false;
320 }
321 let mut at = &mut *v;
322 let (last, parents) = match segs.split_last() {
323 Some(x) => x,
324 None => return false,
325 };
326 for seg in parents {
327 at = match (seg, at) {
328 (Seg::Key(k), Value::Map(m)) => match m.get_mut(k) {
329 Some(x) => x,
330 None => return false,
331 },
332 (Seg::Index(n), Value::List(l)) => match l.get_mut(*n) {
333 Some(x) => x,
334 None => return false,
335 },
336 _ => return false,
337 };
338 }
339 match (last, at) {
340 (Seg::Key(k), Value::Map(m)) => m.remove(k).is_some(),
341 (Seg::Index(n), Value::List(l)) if *n < l.len() => {
342 l.remove(*n);
343 true
344 }
345 _ => false,
346 }
347}
348
349pub fn parse(text: &str, mod_root: Option<&Path>, locale: Option<&str>) -> Result<(Book, Option<String>), String> {
352 let doc = yog_data::text::parse(text).map_err(|e| format!("{}: {}", e.at, e.message))?;
353 let res = Resources::for_mod(mod_root);
354 let texts = texts(mod_root, &res, locale);
355 let mut value = doc.value.clone();
356 let mut first: Option<String> = None;
357 let mut reader = Reader { texts: &texts, missing: Vec::new() };
358 let mut book = None;
359 for _ in 0..32 {
360 match yog_data::from_value_with::<Book>(&value, &mut reader) {
361 Ok(read) => {
362 book = Some(read.value);
363 break;
364 }
365 Err(problems) => {
366 if first.is_none()
367 && let Some(p) = problems.first()
368 {
369 let at = doc.locate(text, &p.path);
370 first = Some(if p.path.is_root() { format!("{at}: {}", p.message) } else { format!("{at}: {}: {}", p.path, p.message) });
371 }
372 let mut changed = false;
374 for p in &problems {
375 changed |= repair(&mut value, &p.path, &p.message);
376 }
377 if !changed {
378 break;
379 }
380 }
381 }
382 }
383 let mut book = book.ok_or_else(|| first.clone().unwrap_or_else(|| "not a book".into()))?;
384 book.i18n = false;
386 book.layouts = mod_root.map(|r| crate::design::mod_ui::recipe_layouts(r).into_iter().map(|(_, l)| l).collect()).unwrap_or_default();
388 book.page_types = mod_root.map(crate::design::mod_ui::page_types).unwrap_or_default();
389 let first = first.or_else(|| reader.missing.first().map(|m| format!("{m}: no such text in resources/*.yres")));
390 if let Ok(mut lang) = LANG.lock() {
391 *lang = texts.lang;
392 }
393 Ok((book, first))
394}
395
396pub fn render(args: &BookArgs) -> BookRendered {
397 let root = mod_root(Path::new(&args.path));
398 let locales = locales(root.as_deref());
399 let locale = args.locale.as_deref().filter(|l| !l.is_empty());
400 let fail = |error: String| BookRendered {
401 image: None,
402 scale: args.scale,
403 shown: None,
404 action: None,
405 animated: false,
406 locales: locales.clone(),
407 error: Some(error),
408 };
409 let (book, problem) = match parse(&args.text, root.as_deref(), locale) {
410 Ok(b) => b,
411 Err(e) => return fail(e),
412 };
413 render_book(args, book, problem, root.as_deref(), locales)
414}
415
416static BOOK_MOD: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
419
420fn book_tag_items(tag: &str) -> Vec<String> {
422 let root = BOOK_MOD.lock().ok().and_then(|r| r.clone());
423 root.map(|r| super::tags::item_tag(&r.join(yog_ids::file::MANIFEST), tag)).unwrap_or_default()
424}
425
426fn render_book(args: &BookArgs, book: yog_formats::book::Book, problem: Option<String>, root: Option<&Path>, locales: Vec<String>) -> BookRendered {
427 yog_book::set_tag_items(book_tag_items);
428 if let Ok(mut m) = BOOK_MOD.lock() {
429 *m = root.map(Path::to_path_buf);
430 }
431 let (sw, sh) = (args.screen_w.clamp(1, 4096), args.screen_h.clamp(1, 4096));
432 let fail = |error: String| BookRendered {
433 image: None,
434 scale: args.scale,
435 shown: None,
436 action: None,
437 animated: false,
438 locales: locales.clone(),
439 error: Some(error),
440 };
441 let json = serde_json::to_string(&book).unwrap_or_default();
442
443 let mut sessions = SESSIONS.lock().unwrap_or_else(|e| e.into_inner());
444 let session = sessions.entry(args.path.clone()).or_insert_with(|| {
445 let mut renderer = BookRenderer::new(book.clone());
446 renderer.set_translator(translate);
447 for (id, recipe) in root.map(recipes).unwrap_or_default() {
448 renderer.add_recipe(id, &recipe);
449 }
450 Session { renderer, json: json.clone() }
451 });
452 if session.json != json {
453 session.renderer.set_book(book);
454 session.json = json;
455 }
456 let r = &mut session.renderer;
457
458 gfx::record(sw as i32, sh as i32, |ctx| r.render(ctx, sw as f32, sh as f32));
460 let action = match &args.action {
461 Some(BookAction::Click { x, y }) => r.click(*x, *y),
462 Some(BookAction::Show { entry, page }) => {
463 r.show(entry, *page);
464 None
465 }
466 Some(BookAction::Home) => {
467 r.handle_event("home");
468 None
469 }
470 None => None,
471 };
472
473 images::set_document(root);
474 let ops = gfx::record(sw as i32, sh as i32, |ctx| r.render(ctx, sw as f32, sh as f32));
475 let res = Resources::for_mod(root);
476 let scale = raster::fit_scale(sw, sh, args.scale);
477 let canvas = raster::rasterize(&ops, sw, sh, scale, &res);
478 match canvas.png() {
479 Ok(png) => BookRendered {
480 image: Some(format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(png))),
481 scale,
482 shown: r.shown(),
483 action,
484 animated: r.animated(),
485 locales,
486 error: problem,
487 },
488 Err(e) => fail(e),
489 }
490}
491
492fn sample(p: &yog_formats::book::page_type::PageProperty) -> serde_json::Value {
495 use yog_formats::book::page_type::PropKind;
496 if let Some(d) = &p.default {
497 return yog_data::json::to_json(d);
498 }
499 match p.kind {
500 PropKind::Text => serde_json::Value::String(format!("‹{}›", p.name)),
501 PropKind::BookText => serde_json::Value::String(format!("‹{}› — the page's text, as a book wraps it.", p.name)),
502 PropKind::Number => serde_json::json!(1),
503 PropKind::Bool => serde_json::json!(true),
504 PropKind::Item => serde_json::json!(yog_ids::game::BOOK),
505 PropKind::Image => serde_json::json!(yog_ids::join(yog_ids::image_scheme::ITEM, yog_ids::game::BOOK)),
506 PropKind::Data => serde_json::Value::Null,
507 }
508}
509
510pub fn page_preview(args: &BookArgs) -> BookRendered {
514 let root = mod_root(Path::new(&args.path));
515 let locales = locales(root.as_deref());
516 let fail = |error: String| BookRendered { image: None, scale: args.scale, shown: None, action: None, animated: false, locales: locales.clone(), error: Some(error) };
517 let ns = root.as_deref().map(crate::design::mod_ui::ns).unwrap_or_else(|| yog_ids::ns::MINECRAFT.into());
518 let kind = match yog_formats::yui::parse(&args.text).and_then(|el| yog_formats::book::page_type::compile(&el, &ns)) {
519 Ok(k) => k,
520 Err(e) => return fail(e),
521 };
522 let mut page = serde_json::Map::new();
523 page.insert("type".into(), serde_json::Value::String(kind.id.clone()));
524 for p in &kind.properties {
525 let v = sample(p);
526 if !v.is_null() {
527 page.insert(p.name.clone(), v);
528 }
529 }
530 let entry = format!("{ns}:page_preview");
531 let text = serde_json::json!({
532 "id": format!("{ns}:page_preview"),
533 "name": kind.id,
534 "categories": [{ "id": entry, "name": kind.id }],
535 "entries": [{ "id": entry, "name": kind.id, "category": format!("@category:{entry}"), "pages": [page] }],
536 })
537 .to_string();
538 let locale = args.locale.as_deref().filter(|l| !l.is_empty());
539 let (mut book, problem) = match parse(&text, root.as_deref(), locale) {
540 Ok(b) => b,
541 Err(e) => return fail(e),
542 };
543 book.page_types.retain(|t| t.id != kind.id);
544 book.page_types.push(kind);
545 let args = BookArgs { action: Some(BookAction::Show { entry, page: 0 }), ..args.clone() };
546 render_book(&args, book, problem, root.as_deref(), locales)
547}
548
549pub fn close(path: &str) {
551 SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).remove(path);
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 #[test]
559 fn resolves_references_to_their_texts() {
560 let mut texts = Texts { refs: HashMap::new(), lang: HashMap::new() };
561 texts.refs.insert("Book.title".to_string(), "Guide".to_string());
562 texts.lang.insert("item.mymod.sapphire".to_string(), "Сапфир".to_string());
563 let v = yog_data::text::parse_value(
564 r#"{
565 name: "@Book.title", subtitle: "@item:mymod:ruby_block", landing_text: "@@x",
566 categories: [{ id: "m:c", name: "@item:mymod:sapphire", icon: "@item:mymod:sapphire" }],
567 entries: [{ id: "m:e", name: "@Book.nope", category: "@category:m:c", pages: [] }],
568 id: "m:b",
569 }"#,
570 )
571 .unwrap();
572 let mut r = Reader { texts: &texts, missing: Vec::new() };
573 let b = yog_data::from_value_with::<Book>(&v, &mut r).unwrap_or_else(|p| panic!("{p:?}")).value;
574 assert_eq!(b.name, "Guide");
575 assert_eq!(b.subtitle.as_deref(), Some("Ruby Block"), "no translation: made from the id");
576 assert_eq!(b.categories[0].name, "Сапфир", "a name in a text");
577 assert_eq!(b.categories[0].icon.as_deref(), Some("mymod:sapphire"), "an id in an id field");
578 assert_eq!(b.entries[0].category, "m:c");
579 assert_eq!(b.landing_text, "@x");
580 assert_eq!(r.missing, ["@Book.nope"]);
581 }
582
583 #[test]
584 fn draws_what_it_can_of_a_broken_book() {
585 let text = r#"{ id: "t:guide", name: "Guide",
586 categories: [{ id: "t:c", name: "C" }],
587 entries: [{ id: "t:e", category: "@category:t:c", pages: [
588 { type: "spotlight", item: "" },
589 { type: "text", text: "kept" },
590 ] }] }"#;
591 let (book, problem) = parse(text, None, None).unwrap();
592 let problem = problem.unwrap();
593 assert!(problem.starts_with("3:23: entries[0]: missing `name`"), "{problem}");
594 assert_eq!(book.entries[0].pages.len(), 1, "the broken page is left out");
595 }
596
597 #[test]
598 fn draws_and_clicks_through_a_book() {
599 let text = r#"{ "id": "t:guide", "name": "Guide",
600 "categories": [{ "id": "t:c", "name": "Basics" }],
601 "entries": [{ "id": "t:e", "name": "First", "category": "@category:t:c",
602 "pages": [{ "type": "text", "text": "one" }, { "type": "text", "text": "two" }] }] }"#;
603 let args = |action| BookArgs { path: "/tmp/test.ybook".into(), text: text.into(), screen_w: 400, screen_h: 240, scale: 1, action, locale: None };
604 let r = render(&args(None));
605 assert!(r.error.is_none() && r.image.is_some(), "{:?}", r.error);
606 assert_eq!(r.shown, None);
607 let r = render(&args(Some(BookAction::Show { entry: "t:e".into(), page: 1 })));
608 assert_eq!(r.shown, Some(("t:e".into(), 1)));
609 let r = render(&args(Some(BookAction::Home)));
610 assert_eq!(r.shown, None);
611 close("/tmp/test.ybook");
612 }
613
614 #[test]
615 fn a_book_page_component_is_drawn_as_the_book_shows_it() {
616 let dir = std::env::temp_dir().join(format!("yog-idle-page-preview-{}", std::process::id()));
617 let _ = std::fs::remove_dir_all(&dir);
618 std::fs::create_dir_all(dir.join("src/ui")).unwrap();
619 std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
620 let yui = r#"<Component name="Note" book_page="note">
621 <Property name="title" type="text"/>
622 <Property name="body" type="book_text" default="Hello"/>
623 <BookHeader text="{x:Bind title}"/>
624 <BookText text="{x:Bind body}"/>
625 <Drawn drawer="mymod:thing" h="32" data="{x:Bind title}"/>
626 </Component>"#;
627 let args = BookArgs {
628 path: dir.join("src/ui/note.yui").to_string_lossy().into_owned(),
629 text: yui.into(),
630 screen_w: 480,
631 screen_h: 270,
632 scale: 1,
633 action: None,
634 locale: None,
635 };
636 let r = page_preview(&args);
637 assert!(r.error.is_none(), "{:?}", r.error);
638 assert!(r.image.is_some());
639 assert_eq!(r.shown, Some(("mymod:page_preview".to_string(), 0)));
640 let _ = std::fs::remove_dir_all(&dir);
641 }
642
643}