diff --git a/CHANGELOG.md b/CHANGELOG.md index 9289d47..e7bafa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,19 @@ # Change Log ## Unreleased - ReleaseDate + +### Features +- Special metadata keys are now an extension. +- Improve `Metadata` memory layout and interface. +- Emoji can now also be a shortcode like `:taco:`. + ### Breaking - (De)Serializing `ScaleOutcome` was not camel case, so (de)serialization has changed from previous versions. +- (De)Serializing format change of `Metadata` special values. Now all special + key values whose parsed values have some different representation are under + the `special` field. +- Removed all fields except `map` from `Metadata`, now they are methods. ## 0.11.1 - 2023-12-28 ### Fixed diff --git a/playground/index.html b/playground/index.html index ff4949d..f192b03 100644 --- a/playground/index.html +++ b/playground/index.html @@ -342,6 +342,7 @@

cooklang-rs playground

"RANGE_VALUES", "TIMER_REQUIRES_TIME", "INTERMEDIATE_PREPARATIONS", + "SPECIAL_METADATA", ].forEach((e, i) => { let bits = 1 << i; if (i == 11) { diff --git a/src/analysis/event_consumer.rs b/src/analysis/event_consumer.rs index d620101..afad37a 100644 --- a/src/analysis/event_consumer.rs +++ b/src/analysis/event_consumer.rs @@ -274,13 +274,15 @@ impl<'i, 'c> RecipeCollector<'i, 'c> { .insert(key_t.to_string(), value_t.to_string()); // check if it's a special key + if !self.extensions.contains(Extensions::SPECIAL_METADATA) { + return; + } if let Ok(sp_key) = SpecialKey::from_str(&key_t) { // try to insert it - let res = self.content.metadata.insert_special_key( - sp_key, - value_t.to_string(), - self.converter, - ); + let res = + self.content + .metadata + .insert_special(sp_key, value_t.to_string(), self.converter); if let Err(err) = res { self.ctx.warn( warning!( @@ -912,7 +914,7 @@ impl<'i, 'c> RecipeCollector<'i, 'c> { } parser::QuantityValue::Many(v) => { const CONFLICT: &str = "Many values conflict"; - if let Some(s) = &self.content.metadata.servings { + if let Some(s) = &self.content.metadata.servings() { let servings_meta_span = self .locations .metadata diff --git a/src/lib.rs b/src/lib.rs index 39bf9f5..5cf7993 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -137,11 +137,8 @@ bitflags! { const TIMER_REQUIRES_TIME = 1 << 10; /// This extensions also enables [`Self::COMPONENT_MODIFIERS`]. const INTERMEDIATE_PREPARATIONS = 1 << 11 | Self::COMPONENT_MODIFIERS.bits(); - - /// Enables [`Self::COMPONENT_MODIFIERS`], [`Self::COMPONENT_NOTE`] and [`Self::COMPONENT_ALIAS`] - const COMPONENT_ALL = Self::COMPONENT_MODIFIERS.bits() - | Self::COMPONENT_ALIAS.bits() - | Self::COMPONENT_NOTE.bits(); + /// Enables special metadata key parsing + const SPECIAL_METADATA = 1 << 12; /// Enables a subset of extensions to maximize compatibility with other /// cooklang parsers. @@ -159,7 +156,8 @@ bitflags! { | Self::TEMPERATURE.bits() | Self::TEXT_STEPS.bits() | Self::RANGE_VALUES.bits() - | Self::INTERMEDIATE_PREPARATIONS.bits(); + | Self::INTERMEDIATE_PREPARATIONS.bits() + | Self::SPECIAL_METADATA.bits(); } } diff --git a/src/metadata.rs b/src/metadata.rs index ec35ab4..cfb9eda 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -1,6 +1,6 @@ //! Metadata of a recipe -use std::{num::ParseFloatError, str::FromStr}; +use std::{collections::HashMap, num::ParseFloatError, str::FromStr}; pub use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -27,32 +27,123 @@ use crate::{ /// Metadata of a recipe /// -/// The fields on this struct are the parsed values with some special meaning. /// The raw key/value pairs from the recipe are in the `map` field. -/// -/// This struct is non exhaustive because adding a new special metadata value -/// is not a breaking change. +/// Many methods on this struct are the parsed values with some special meaning. +/// They return `None` if the key is missing or the value failed to parse. #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)] -#[non_exhaustive] pub struct Metadata { + special: HashMap, + /// All the raw key/value pairs from the recipe + pub map: IndexMap, +} + +#[derive( + Debug, + Clone, + Copy, + strum::Display, + strum::EnumString, + strum::AsRefStr, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub(crate) enum SpecialKey { + Description, + #[strum(serialize = "tag", to_string = "tags")] + Tags, + Emoji, + Author, + Source, + Time, + #[strum(serialize = "prep_time", to_string = "prep time")] + PrepTime, + #[strum(serialize = "cook_time", to_string = "cook time")] + CookTime, + Servings, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +enum SpecialValue { + Tags(Vec), + NameAndUrl(NameAndUrl), + Time(RecipeTime), + Servings(Vec), + String(String), +} + +macro_rules! unwrap_value { + ($variant:ident, $value:expr) => { + if let crate::metadata::SpecialValue::$variant(inner) = $value { + inner + } else { + panic!( + "Unexpected special value variant. Expected '{}' but got '{:?}'", + stringify!(SpecialValue::$variant), + $value + ); + } + }; +} + +impl Metadata { /// Description of the recipe - pub description: Option, - /// List of tags - pub tags: Vec, + pub fn description(&self) -> Option<&str> { + self.map + .get(SpecialKey::Description.as_ref()) + .map(|s| s.as_str()) + } + /// Emoji for the recipe - pub emoji: Option, + pub fn emoji(&self) -> Option<&str> { + self.special + .get(&SpecialKey::Emoji) + .map(|v| unwrap_value!(String, v).as_str()) + } + + /// List of tags + pub fn tags(&self) -> Option<&[String]> { + self.special + .get(&SpecialKey::Tags) + .map(|v| unwrap_value!(Tags, v).as_slice()) + } + /// Author - pub author: Option, + /// + /// This *who* wrote the recipe. + pub fn author(&self) -> Option<&NameAndUrl> { + self.special + .get(&SpecialKey::Author) + .map(|v| unwrap_value!(NameAndUrl, v)) + } + /// Source /// - /// This *where* the recipe was obtained from. It's different from author. - pub source: Option, + /// This *where* the recipe was obtained from. + pub fn source(&self) -> Option<&NameAndUrl> { + self.special + .get(&SpecialKey::Source) + .map(|v| unwrap_value!(NameAndUrl, v)) + } + /// Time it takes to prepare/cook the recipe - pub time: Option, + pub fn time(&self) -> Option<&RecipeTime> { + self.special + .get(&SpecialKey::Time) + .map(|v| unwrap_value!(Time, v)) + } + /// Servings the recipe is made for - pub servings: Option>, - /// All the raw key/value pairs from the recipe - pub map: IndexMap, + pub fn servings(&self) -> Option<&[u32]> { + self.special + .get(&SpecialKey::Servings) + .map(|v| unwrap_value!(Servings, v).as_slice()) + } } /// Combination of name and URL. @@ -84,32 +175,17 @@ pub enum RecipeTime { }, } -#[derive(Debug, Clone, Copy, strum::Display, strum::EnumString, PartialEq, Eq, Hash)] -#[strum(serialize_all = "snake_case")] -pub(crate) enum SpecialKey { - Description, - #[strum(serialize = "tag", to_string = "tags")] - Tags, - Emoji, - Author, - Source, - Time, - #[strum(serialize = "prep_time", to_string = "prep time")] - PrepTime, - #[strum(serialize = "cook_time", to_string = "cook time")] - CookTime, - Servings, -} - impl Metadata { - pub(crate) fn insert_special_key( + pub(crate) fn insert_special( &mut self, key: SpecialKey, value: String, converter: &Converter, ) -> Result<(), MetadataError> { match key { - SpecialKey::Description => self.description = Some(value), + SpecialKey::Description => { + self.map.insert(key.as_ref().to_string(), value); + } SpecialKey::Tags => { let new_tags = value .split(',') @@ -118,37 +194,57 @@ impl Metadata { if new_tags.iter().any(|t| !is_valid_tag(t)) { return Err(MetadataError::InvalidTag { tag: value }); } - self.tags.extend(new_tags); + + let tags_val = self + .special + .entry(key) + .or_insert_with(|| SpecialValue::Tags(Vec::new())); + unwrap_value!(Tags, tags_val).extend(new_tags); } SpecialKey::Emoji => { - if emojis::get(&value).is_some() { - self.emoji = Some(value); + let emoji = if value.starts_with(':') && value.ends_with(':') { + emojis::get_by_shortcode(&value[1..value.len() - 1]) + } else { + emojis::get(&value) + }; + if let Some(emoji) = emoji { + self.special + .insert(key, SpecialValue::String(emoji.to_string())); } else { return Err(MetadataError::NotEmoji { value }); } } - SpecialKey::Author => self.author = Some(NameAndUrl::parse(&value)), - SpecialKey::Source => self.source = Some(NameAndUrl::parse(&value)), - SpecialKey::Time => self.time = Some(RecipeTime::Total(parse_time(&value, converter)?)), + SpecialKey::Author | SpecialKey::Source => { + self.special + .insert(key, SpecialValue::NameAndUrl(NameAndUrl::parse(&value))); + } + SpecialKey::Time => { + let time = RecipeTime::Total(parse_time(&value, converter)?); + self.special.insert(key, SpecialValue::Time(time)); + } SpecialKey::PrepTime => { - let cook_time = self.time.and_then(|t| match t { + let cook_time = self.time().and_then(|t| match t { RecipeTime::Total(_) => None, - RecipeTime::Composed { cook_time, .. } => cook_time, + RecipeTime::Composed { cook_time, .. } => *cook_time, }); - self.time = Some(RecipeTime::Composed { + let time = RecipeTime::Composed { prep_time: Some(parse_time(&value, converter)?), cook_time, - }); + }; + self.special + .insert(SpecialKey::Time, SpecialValue::Time(time)); } SpecialKey::CookTime => { - let prep_time = self.time.and_then(|t| match t { + let prep_time = self.time().and_then(|t| match t { RecipeTime::Total(_) => None, - RecipeTime::Composed { prep_time, .. } => prep_time, + RecipeTime::Composed { prep_time, .. } => *prep_time, }); - self.time = Some(RecipeTime::Composed { + let time = RecipeTime::Composed { prep_time, cook_time: Some(parse_time(&value, converter)?), - }); + }; + self.special + .insert(SpecialKey::Time, SpecialValue::Time(time)); } SpecialKey::Servings => { let servings = value @@ -166,12 +262,39 @@ impl Metadata { if l != dedup_l { return Err(MetadataError::DuplicateServings { servings }); } - self.servings = Some(servings); + self.special + .insert(SpecialKey::Servings, SpecialValue::Servings(servings)); } } Ok(()) } + /// Parse the inner [map](Self::map) updating the special keys + /// + /// This can be useful if you edit the inner values of the metadata map and + /// want the special keys to refresh. + /// + /// The error variant of the result contains the key value pairs that had + /// an error parsing. Even if [`Err`] is returned, some values may have been + /// updated. + pub fn parse_special(&mut self, converter: &Converter) -> Result<(), Vec<(String, String)>> { + let mut new = Self::default(); + let mut errors = Vec::new(); + for (key, val) in &self.map { + if let Ok(sp_key) = SpecialKey::from_str(key) { + if new.insert_special(sp_key, val.clone(), converter).is_err() { + errors.push((key.clone(), val.clone())); + } + } + } + self.special = new.special; + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } + /// Iterates over [`Self::map`] but with all *special* metadata values /// skipped pub fn map_filtered(&self) -> impl Iterator { @@ -463,4 +586,64 @@ mod tests { t_alias("cook_time", SpecialKey::CookTime); t("servings", SpecialKey::Servings); } + + // To ensure no panics in unwrap_value + #[test] + fn special_key_access() { + let converter = Converter::empty(); + let mut m = Metadata::default(); + + let _ = m.insert_special( + SpecialKey::Description, + "Description".to_string(), + &converter, + ); + assert!(matches!(m.description(), Some(_))); + + let _ = m.insert_special(SpecialKey::Tags, "t1, t2".to_string(), &converter); + assert!(matches!(m.tags(), Some(_))); + + let _ = m.insert_special(SpecialKey::Emoji, "⛄".to_string(), &converter); + assert!(matches!(m.emoji(), Some(_))); + + let _ = m.insert_special(SpecialKey::Author, "Rachel".to_string(), &converter); + assert!(matches!(m.author(), Some(_))); + + let _ = m.insert_special(SpecialKey::Source, "Mom's cookbook".to_string(), &converter); + assert!(matches!(m.source(), Some(_))); + + let _ = m.insert_special(SpecialKey::PrepTime, "3 min".to_string(), &converter); + assert!(matches!(m.time(), Some(_))); + m.special.remove(&SpecialKey::Time); + + let _ = m.insert_special(SpecialKey::CookTime, "3 min".to_string(), &converter); + assert!(matches!(m.time(), Some(_))); + m.special.remove(&SpecialKey::Time); + + let _ = m.insert_special(SpecialKey::Time, "3 min".to_string(), &converter); + assert!(matches!(m.time(), Some(_))); + m.special.remove(&SpecialKey::Time); + + let _ = m.insert_special(SpecialKey::Servings, "3|4".to_string(), &converter); + assert!(matches!(m.servings(), Some(_))); + } + + #[test] + fn shortcode_emoji() { + let converter = Converter::empty(); + + let mut m = Metadata::default(); + let r = m.insert_special(SpecialKey::Emoji, "taco".to_string(), &converter); + assert!(r.is_err()); + + let mut m = Metadata::default(); + let r = m.insert_special(SpecialKey::Emoji, ":taco:".to_string(), &converter); + assert!(r.is_ok()); + assert_eq!(m.emoji(), Some("🌮")); + + let mut m = Metadata::default(); + let r = m.insert_special(SpecialKey::Emoji, "🌮".to_string(), &converter); + assert!(r.is_ok()); + assert_eq!(m.emoji(), Some("🌮")); + } } diff --git a/src/scale.rs b/src/scale.rs index d70f9d8..0ec860b 100644 --- a/src/scale.rs +++ b/src/scale.rs @@ -125,7 +125,7 @@ impl ScalableRecipe { /// Note that this returns a [`ScaledRecipe`] wich doesn't implement this /// method. A recipe can only be scaled once. pub fn scale(self, target: u32, converter: &Converter) -> ScaledRecipe { - let target = if let Some(servings) = self.metadata.servings.as_ref() { + let target = if let Some(servings) = self.metadata.servings() { let base = servings.first().copied().unwrap_or(1); ScaleTarget::new(base, target, servings) } else { diff --git a/tests/serde.rs b/tests/serde.rs index bd07791..2e5de6b 100644 --- a/tests/serde.rs +++ b/tests/serde.rs @@ -8,6 +8,9 @@ use cooklang::parse; const RECIPE: &str = r#" +>> description: desc +>> time: 3 min + A step with @ingredients{}. References to @&ingredients{}, #cookware, ~timers{3%min}.