commit 895b0a2f02385b61892a2c09f76b6e628113fa2c Author: Joakim Hulthe Date: Fri May 8 21:51:37 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3a5d2f0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "nethys-data"] + path = nethys-data + url = https://git.nubo.sh/hulthe/nethys-data.git diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b329337 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,295 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nethys-to-obsidian" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f84d846 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "nethys-to-obsidian" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.102" +clap = { version = "4.6.1", features = ["derive", "env"] } +regex = "1.12.3" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" diff --git a/nethys-data b/nethys-data new file mode 160000 index 0000000..db25909 --- /dev/null +++ b/nethys-data @@ -0,0 +1 @@ +Subproject commit db259093e4868fcff9a41a6501a73f93cea94dd8 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1627d4e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,218 @@ +use anyhow::Context; +use clap::Parser; +use regex::Regex; +use serde::Deserialize; +use std::fs; +use std::io::{Read, stdin}; +use std::path::PathBuf; + +/// 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, +} + +#[allow(dead_code)] +#[derive(Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +struct CharacterSb { + name: String, + #[serde(rename = "type")] + kind: String, + level: serde_json::Value, + #[serde(rename = "trait")] + traits: Vec, + skill_markdown: Option, + hp_raw: String, + perception: i32, + sense_markdown: String, + strength: i32, + dexterity: i32, + constitution: i32, + intelligence: i32, + wisdom: i32, + charisma: i32, + ac: i32, + fortitude_save: i32, + reflex_save: i32, + will_save: i32, + immunity_markdown: Option, + resistance_markdown: Option, + weakness_markdown: Option, + speed_markdown: String, + markdown: String, + /// If this is a legacy creature that has been remastered, this will be `Some` + remaster_id: Option>, + /// If this is a remastered creature, this will be `Some` + legacy_id: Option>, +} + +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**"), + ) +} + +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 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); + + 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```"); + + Ok(()) +} + +fn plussed(number: i32) -> String { + if number >= 0 { + format!("+{number}") + } else { + format!("{number}") + } +}