diff --git a/src/character.rs b/src/character.rs index 9c65ac4..ba4f8aa 100644 --- a/src/character.rs +++ b/src/character.rs @@ -1,19 +1,27 @@ +use std::{fmt::Write as _, sync::LazyLock}; + +use regex::Regex; use serde::Deserialize; +pub static CREATURES: LazyLock> = LazyLock::new(|| { + let characters = include_str!("../nethys-data/data/creatures.json"); + serde_json::from_str(characters).expect("Failed to parse JSON file") +}); + #[allow(dead_code)] #[derive(Deserialize, Debug)] #[serde(rename_all = "snake_case")] -pub struct CharacterSb { +pub struct Creature { pub name: String, #[serde(rename = "type")] pub kind: String, - pub level: serde_json::Value, + pub level: i32, #[serde(rename = "trait")] pub traits: Vec, pub skill_markdown: Option, pub hp_raw: String, pub perception: i32, - pub sense_markdown: String, + pub sense_markdown: Option, pub strength: i32, pub dexterity: i32, pub constitution: i32, @@ -27,10 +35,167 @@ pub struct CharacterSb { pub immunity_markdown: Option, pub resistance_markdown: Option, pub weakness_markdown: Option, - pub speed_markdown: String, + pub speed_markdown: Option, pub markdown: String, /// If this is a legacy creature that has been remastered, this will be `Some` pub remaster_id: Option>, /// If this is a remastered creature, this will be `Some` pub legacy_id: Option>, } + +fn fix_action_icons(input: String) -> String { + input + .replace(r#""#, "`[free-action]`") + .replace(r#""#, "`[one-action]`") + .replace(r#""#, "`[two-actions]`") + .replace(r#""#, "`[three-actions]`") + .replace(r#""#, "`[reaction]`") +} + +fn parse_ability_section( + markdown: &str, + mut section_start: impl FnMut(&str) -> bool, + mut section_end: impl FnMut(&str) -> bool, +) -> String { + let re_split = Regex::new(r"\n\s*\n").unwrap(); + let sections: Vec<&str> = re_split.split(markdown).collect(); + + let relevant_sections = sections + .into_iter() + .map(|s| s.trim_start()) + .filter(|s| s.starts_with("**")) + .skip_while(|s| !section_start(s)) + .skip(1) + .take_while(|s| !section_end(s)); + + let processed: Vec = relevant_sections + .map(|s| s.replace("\r", "")) + .map(|s| fix_action_icons(s.to_string())) + .map(|s| s.replace('\n', " ")) + .collect(); + + processed.join("\n") +} + +fn parse_offensive_abilities(markdown: &str) -> String { + parse_ability_section(markdown, |s| s.starts_with("**Speed**"), |_| false) +} + +fn parse_defensive_abilities(markdown: &str) -> String { + let re_split = Regex::new(r"\n\s*\n").unwrap(); + let sections: Vec<&str> = re_split.split(markdown).collect(); + + let relevant_sections = sections + .into_iter() + .map(|s| s.trim_start()) + .filter(|s| s.starts_with("**")) + .skip_while(|s| !s.starts_with("**HP**")) + .skip(1) + .skip_while(|s| { + s.starts_with("**Immunities**") + || s.starts_with("**Resistances**") + || s.starts_with("**Weaknesses**") + }) + .take_while(|s| !s.starts_with("**Speed**")); + + let processed: Vec = relevant_sections + .map(|s| s.replace("\r", "")) + .map(|s| fix_action_icons(s.to_string())) + .map(|s| s.replace('\n', " ")) + .collect(); + + processed.join("\n") +} + +fn parse_general_abilities(markdown: &str) -> String { + parse_ability_section( + markdown, + |s| s.starts_with("**Cha**"), + |s| s.starts_with("**Fort**"), + ) +} + +fn plussed(number: i32) -> String { + if number >= 0 { + format!("+{number}") + } else { + format!("{number}") + } +} + +pub fn creature_to_obsidian(sb: &Creature) -> String { + let traits_str = sb + .traits + .iter() + .map(|t| format!("=={}==", t)) + .collect::>() + .join(" "); + + let hp_text = format!("**HP** {}", &sb.hp_raw); + let hp_stuff = [ + ("Immunities", &sb.immunity_markdown), + ("Resistances", &sb.resistance_markdown), + ("Weaknesses", &sb.weakness_markdown), + ] + .into_iter() + .filter_map(|(prefix, list)| Some(prefix).zip(list.as_deref())) + .map(|(prefix, list)| format!("**{prefix}** {list}")) + .fold(hp_text, |b, s| b + "; " + &s); + + let general_abilities = parse_general_abilities(&sb.markdown); + let defensive_abilities = parse_defensive_abilities(&sb.markdown); + let offensive_abilities = parse_offensive_abilities(&sb.markdown); + + let mut s = String::new(); + + _ = writeln!(&mut s, "```pf2e-stats"); + _ = writeln!(&mut s, "# {}", sb.name); + _ = writeln!(&mut s, "## {} {}", sb.kind, sb.level); + _ = writeln!(&mut s, "---"); + _ = writeln!(&mut s, "\n{}\n", traits_str); + _ = writeln!( + &mut s, + "\n**Perception** {}; {}\n", + plussed(sb.perception), + &sb.sense_markdown.as_deref().unwrap_or(""), // TODO + ); + _ = writeln!( + &mut s, + "\n**Skills**: {}\n", + sb.skill_markdown.as_deref().unwrap_or_default() // TODO + ); + _ = writeln!( + &mut s, + "\n**Str** {}, **Dex** {}, **Con** {}, **Int** {}, **Wis** {}, **Cha** {}", + plussed(sb.strength), + plussed(sb.dexterity), + plussed(sb.constitution), + plussed(sb.intelligence), + plussed(sb.wisdom), + plussed(sb.charisma), + ); + _ = writeln!(&mut s, "{general_abilities}"); + _ = writeln!(&mut s,); + _ = writeln!(&mut s, "---"); + _ = writeln!( + &mut s, + "\n**AC** {}; **Fort** {}, **Ref** {} **Will** {}", + &sb.ac, + plussed(sb.fortitude_save), + plussed(sb.reflex_save), + plussed(sb.will_save), + ); + _ = writeln!(&mut s, "{}", hp_stuff); + _ = writeln!(&mut s, "{defensive_abilities}"); + _ = writeln!(&mut s); + _ = writeln!(&mut s, "\n---"); + _ = writeln!( + &mut s, + "\n**Speed**: {}\n", + &sb.speed_markdown.as_deref().unwrap_or_default() // TODO + ); + _ = writeln!(&mut s, "{}", offensive_abilities); + _ = writeln!(&mut s, "\n```"); + + s +} diff --git a/src/main.rs b/src/main.rs index f38df41..c817edf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,183 +1,32 @@ -use anyhow::Context; -use clap::Parser; -use nethys_to_obsidian::character::CharacterSb; -use regex::Regex; -use std::fs; -use std::io::{Read, stdin}; -use std::path::PathBuf; +use anyhow::anyhow; +use clap::{Parser, Subcommand}; +use nethys_to_obsidian::character::{CREATURES, Creature, creature_to_obsidian}; /// Convert statblocks from Archives of Nethys into markdownish-blocks compatible with pf2e-stats obsidian plugin. #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { - /// Path to the JSON file. If none, the JSON will be read from stdin. - path: Option, + #[clap(subcommand)] + command: Command, } -fn format_val(v: &serde_json::Value) -> String { - if v.is_string() { - v.as_str().unwrap_or("").to_string() - } else { - v.to_string().replace('\"', "") - } -} - -fn fix_action_icons(input: String) -> String { - input - .replace(r#""#, "`[free-action]`") - .replace(r#""#, "`[one-action]`") - .replace(r#""#, "`[two-actions]`") - .replace(r#""#, "`[three-actions]`") - .replace(r#""#, "`[reaction]`") -} - -fn parse_ability_section( - markdown: &str, - mut section_start: impl FnMut(&str) -> bool, - mut section_end: impl FnMut(&str) -> bool, -) -> String { - let re_split = Regex::new(r"\n\s*\n").unwrap(); - let sections: Vec<&str> = re_split.split(markdown).collect(); - - let relevant_sections = sections - .into_iter() - .map(|s| s.trim_start()) - .filter(|s| s.starts_with("**")) - .skip_while(|s| !section_start(s)) - .skip(1) - .take_while(|s| !section_end(s)); - - let processed: Vec = relevant_sections - .map(|s| s.replace("\r", "")) - .map(|s| fix_action_icons(s.to_string())) - .map(|s| s.replace('\n', " ")) - .collect(); - - processed.join("\n") -} - -fn parse_offensive_abilities(markdown: &str) -> String { - parse_ability_section(markdown, |s| s.starts_with("**Speed**"), |_| false) -} - -fn parse_defensive_abilities(markdown: &str) -> String { - let re_split = Regex::new(r"\n\s*\n").unwrap(); - let sections: Vec<&str> = re_split.split(markdown).collect(); - - let relevant_sections = sections - .into_iter() - .map(|s| s.trim_start()) - .map(|s| dbg!(s)) - .filter(|s| s.starts_with("**")) - .skip_while(|s| !s.starts_with("**HP**")) - .skip(1) - .skip_while(|s| { - s.starts_with("**Immunities**") - || s.starts_with("**Resistances**") - || s.starts_with("**Weaknesses**") - }) - .take_while(|s| !s.starts_with("**Speed**")); - - let processed: Vec = relevant_sections - .map(|s| s.replace("\r", "")) - .map(|s| fix_action_icons(s.to_string())) - .map(|s| s.replace('\n', " ")) - .collect(); - - processed.join("\n") -} - -fn parse_general_abilities(markdown: &str) -> String { - parse_ability_section( - markdown, - |s| s.starts_with("**Cha**"), - |s| s.starts_with("**Fort**"), - ) +#[derive(Subcommand, Debug, Clone)] +enum Command { + Creature { name: String }, } fn main() -> anyhow::Result<()> { let args = Args::parse(); - let content = if let Some(path) = &args.path { - fs::read_to_string(path).context("Failed to read JSON file")? - } else { - let mut s = String::new(); - stdin() - .read_to_string(&mut s) - .context("Failed to read from stdin")?; - s - }; - let sb: CharacterSb = serde_json::from_str(&content).context("Failed to parse JSON file")?; + let Command::Creature { name } = args.command; - let traits_str = sb - .traits - .iter() - .map(|t| format!("=={}==", t)) - .collect::>() - .join(" "); + let mut candidates: Vec<&Creature> = CREATURES.iter().filter(|c| &c.name == &name).collect(); - let hp_text = format!("**HP** {}", &sb.hp_raw); - let hp_stuff = [ - ("Immunities", &sb.immunity_markdown), - ("Resistances", &sb.resistance_markdown), - ("Weaknesses", &sb.weakness_markdown), - ] - .into_iter() - .filter_map(|(prefix, list)| Some(prefix).zip(list.as_deref())) - .map(|(prefix, list)| format!("**{prefix}** {list}")) - .fold(hp_text, |b, s| b + "; " + &s); + candidates.sort_unstable_by_key(|&creature| creature.legacy_id.is_none()); + let &creature = candidates + .first() + .ok_or(anyhow!("No creature with that name"))?; - let general_abilities = parse_general_abilities(&sb.markdown); - let defensive_abilities = parse_defensive_abilities(&sb.markdown); - let offensive_abilities = parse_offensive_abilities(&sb.markdown); - - println!("```pf2e-stats"); - println!("# {}", sb.name); - println!("## {} {}", sb.kind, format_val(&sb.level)); - println!("---"); - println!("\n{}\n", traits_str); - println!( - "\n**Perception** {}; {}\n", - plussed(sb.perception), - &sb.sense_markdown, - ); - println!( - "\n**Skills**: {}\n", - sb.skill_markdown.as_deref().unwrap_or_default() // TODO - ); - println!( - "\n**Str** {}, **Dex** {}, **Con** {}, **Int** {}, **Wis** {}, **Cha** {}", - plussed(sb.strength), - plussed(sb.dexterity), - plussed(sb.constitution), - plussed(sb.intelligence), - plussed(sb.wisdom), - plussed(sb.charisma), - ); - println!("{general_abilities}"); - println!(); - println!("---"); - println!( - "\n**AC** {}; **Fort** {}, **Ref** {} **Will** {}", - &sb.ac, - plussed(sb.fortitude_save), - plussed(sb.reflex_save), - plussed(sb.will_save), - ); - println!("{}", hp_stuff); - println!("{defensive_abilities}"); - println!(); - println!("\n---"); - println!("\n**Speed**: {}\n", &sb.speed_markdown); - println!("{}", offensive_abilities); - println!("\n```"); + print!("{}", creature_to_obsidian(creature)); Ok(()) } - -fn plussed(number: i32) -> String { - if number >= 0 { - format!("+{number}") - } else { - format!("{number}") - } -}