cooklang_bindings/
model.rs

1use std::collections::HashMap;
2
3use cooklang::model::Item as OriginalItem;
4use cooklang::quantity::{
5    Quantity as OriginalQuantity, ScalableValue as OriginalScalableValue, Value as OriginalValue,
6};
7use cooklang::ScalableRecipe as OriginalRecipe;
8
9#[derive(uniffi::Record, Debug)]
10pub struct CooklangRecipe {
11    pub metadata: HashMap<String, String>,
12    pub sections: Vec<Section>,
13    pub ingredients: Vec<Ingredient>,
14    pub cookware: Vec<Cookware>,
15    pub timers: Vec<Timer>,
16}
17pub type ComponentRef = u32;
18
19#[derive(uniffi::Record, Debug)]
20pub struct Section {
21    pub title: Option<String>,
22    pub blocks: Vec<Block>,
23    pub ingredient_refs: Vec<ComponentRef>,
24    pub cookware_refs: Vec<ComponentRef>,
25    pub timer_refs: Vec<ComponentRef>,
26}
27
28#[derive(uniffi::Enum, Debug)]
29pub enum Block {
30    StepBlock(Step),
31    NoteBlock(BlockNote),
32}
33
34#[derive(uniffi::Enum, Debug, PartialEq)]
35pub enum Component {
36    IngredientComponent(Ingredient),
37    CookwareComponent(Cookware),
38    TimerComponent(Timer),
39    TextComponent(String),
40}
41
42#[derive(uniffi::Record, Debug)]
43pub struct Step {
44    pub items: Vec<Item>,
45    pub ingredient_refs: Vec<ComponentRef>,
46    pub cookware_refs: Vec<ComponentRef>,
47    pub timer_refs: Vec<ComponentRef>,
48}
49
50#[derive(uniffi::Record, Debug)]
51pub struct BlockNote {
52    pub text: String,
53}
54
55#[derive(uniffi::Record, Debug, PartialEq, Clone)]
56pub struct Ingredient {
57    pub name: String,
58    pub amount: Option<Amount>,
59    pub descriptor: Option<String>,
60}
61
62#[derive(uniffi::Record, Debug, PartialEq, Clone)]
63pub struct Cookware {
64    pub name: String,
65    pub amount: Option<Amount>,
66}
67
68#[derive(uniffi::Record, Debug, PartialEq, Clone)]
69pub struct Timer {
70    pub name: Option<String>,
71    pub amount: Option<Amount>,
72}
73
74#[derive(uniffi::Enum, Debug, Clone, PartialEq)]
75pub enum Item {
76    Text { value: String },
77    IngredientRef { index: ComponentRef },
78    CookwareRef { index: ComponentRef },
79    TimerRef { index: ComponentRef },
80}
81
82pub type IngredientList = HashMap<String, GroupedQuantity>;
83
84pub(crate) fn into_group_quantity(amount: &Option<Amount>) -> GroupedQuantity {
85    // options here:
86    // - same units:
87    //    - same value type
88    //    - not the same value type
89    // - different units
90    // - no units
91    // - no amount
92    //
93    // \
94    //  |- <litre,Number> => 1.2
95    //  |- <litre,Text> => half
96    //  |- <,Text> => pinch
97    //  |- <,Empty> => Some
98    //
99    //
100    // TODO define rules on language spec level???
101    let empty_units = "".to_string();
102
103    let key = if let Some(amount) = amount {
104        let units = amount.units.as_ref().unwrap_or(&empty_units);
105
106        match &amount.quantity {
107            Value::Number { .. } => GroupedQuantityKey {
108                name: units.to_string(),
109                unit_type: QuantityType::Number,
110            },
111            Value::Range { .. } => GroupedQuantityKey {
112                name: units.to_string(),
113                unit_type: QuantityType::Range,
114            },
115            Value::Text { .. } => GroupedQuantityKey {
116                name: units.to_string(),
117                unit_type: QuantityType::Text,
118            },
119            Value::Empty => GroupedQuantityKey {
120                name: units.to_string(),
121                unit_type: QuantityType::Empty,
122            },
123        }
124    } else {
125        GroupedQuantityKey {
126            name: empty_units,
127            unit_type: QuantityType::Empty,
128        }
129    };
130
131    let value = if let Some(amount) = amount {
132        amount.quantity.clone()
133    } else {
134        Value::Empty
135    };
136
137    GroupedQuantity::from([(key, value)])
138}
139
140#[derive(uniffi::Enum, Debug, Clone, Hash, Eq, PartialEq)]
141pub enum QuantityType {
142    Number,
143    Range, // how to combine ranges?
144    Text,
145    Empty,
146}
147
148#[derive(uniffi::Record, Debug, Clone, Hash, Eq, PartialEq)]
149pub struct GroupedQuantityKey {
150    pub name: String,
151    pub unit_type: QuantityType,
152}
153
154pub type GroupedQuantity = HashMap<GroupedQuantityKey, Value>;
155
156#[derive(uniffi::Record, Debug, Clone, PartialEq)]
157pub struct Amount {
158    pub(crate) quantity: Value,
159    pub(crate) units: Option<String>,
160}
161
162#[derive(uniffi::Enum, Debug, Clone, PartialEq)]
163pub enum Value {
164    Number { value: f64 },
165    Range { start: f64, end: f64 },
166    Text { value: String },
167    Empty,
168}
169
170// TODO, should be more complex and support canonical keys
171pub type CooklangMetadata = HashMap<String, String>;
172
173trait Amountable {
174    fn extract_amount(&self) -> Amount;
175}
176
177impl Amountable for OriginalQuantity<OriginalScalableValue> {
178    fn extract_amount(&self) -> Amount {
179        let quantity = extract_quantity(&self.value);
180
181        let units = self.unit().as_ref().map(|u| u.to_string());
182
183        Amount { quantity, units }
184    }
185}
186
187impl Amountable for OriginalScalableValue {
188    fn extract_amount(&self) -> Amount {
189        let quantity = extract_quantity(self);
190
191        Amount {
192            quantity,
193            units: None,
194        }
195    }
196}
197
198fn extract_quantity(value: &OriginalScalableValue) -> Value {
199    match value {
200        OriginalScalableValue::Fixed(value) => extract_value(value),
201        OriginalScalableValue::Linear(value) => extract_value(value),
202        OriginalScalableValue::ByServings(values) => extract_value(values.first().unwrap()),
203    }
204}
205
206fn extract_value(value: &OriginalValue) -> Value {
207    match value {
208        OriginalValue::Number(num) => Value::Number { value: num.value() },
209        OriginalValue::Range { start, end } => Value::Range {
210            start: start.value(),
211            end: end.value(),
212        },
213        OriginalValue::Text(value) => Value::Text {
214            value: value.to_string(),
215        },
216    }
217}
218
219pub fn expand_with_ingredients(
220    ingredients: &[Ingredient],
221    base: &mut IngredientList,
222    addition: &Vec<ComponentRef>,
223) {
224    for index in addition {
225        let ingredient = ingredients.get(*index as usize).unwrap().clone();
226        let quantity = into_group_quantity(&ingredient.amount);
227        add_to_ingredient_list(base, &ingredient.name, &quantity);
228    }
229}
230
231// I(dubadub) haven't found a way to export these methods with mutable argument
232fn add_to_ingredient_list(
233    list: &mut IngredientList,
234    name: &String,
235    quantity_to_add: &GroupedQuantity,
236) {
237    if let Some(quantity) = list.get_mut(name) {
238        merge_grouped_quantities(quantity, quantity_to_add);
239    } else {
240        list.insert(name.to_string(), quantity_to_add.clone());
241    }
242}
243
244// O(n2)? find a better way
245pub fn merge_ingredient_lists(left: &mut IngredientList, right: &IngredientList) {
246    right
247        .iter()
248        .for_each(|(ingredient_name, grouped_quantity)| {
249            let quantity = left.entry(ingredient_name.to_string()).or_default();
250
251            merge_grouped_quantities(quantity, grouped_quantity);
252        });
253}
254
255// I(dubadub) haven't found a way to export these methods with mutable argument
256// Right should be always smaller?
257pub(crate) fn merge_grouped_quantities(left: &mut GroupedQuantity, right: &GroupedQuantity) {
258    // options here:
259    // - same units:
260    //    - same value type
261    //    - not the same value type
262    // - different units
263    // - no units
264    // - no amount
265    //
266    // \
267    //  |- <litre,Number> => 1.2 litre
268    //  |- <litre,Text> => half litre
269    //  |- <,Text> => pinch
270    //  |- <,Empty> => Some
271    //
272    //
273    // TODO define rules on language spec level
274
275    right.iter().for_each(|(key, value)| {
276        left.entry(key.clone()) // isn't really necessary?
277            .and_modify(|v| {
278                match key.unit_type {
279                    QuantityType::Number => {
280                        let Value::Number { value: assignable } = value else {
281                            panic!("Unexpected type")
282                        };
283                        let Value::Number { value: stored } = v else {
284                            panic!("Unexpected type")
285                        };
286
287                        *stored += assignable
288                    }
289                    QuantityType::Range => {
290                        let Value::Range { start, end } = value else {
291                            panic!("Unexpected type")
292                        };
293                        let Value::Range { start: s, end: e } = v else {
294                            panic!("Unexpected type")
295                        };
296
297                        // is it even correct?
298                        *s += start;
299                        *e += end;
300                    }
301                    QuantityType::Text => {
302                        let Value::Text {
303                            value: ref assignable,
304                        } = value
305                        else {
306                            panic!("Unexpected type")
307                        };
308                        let Value::Text { value: stored } = v else {
309                            panic!("Unexpected type")
310                        };
311
312                        *stored += assignable;
313                    }
314                    QuantityType::Empty => {} // nothing is required to do, Some + Some = Some
315                }
316            })
317            .or_insert(value.clone());
318    });
319}
320
321pub(crate) fn into_item(item: &OriginalItem) -> Item {
322    match item {
323        OriginalItem::Text { value } => Item::Text {
324            value: value.to_string(),
325        },
326        OriginalItem::Ingredient { index } => Item::IngredientRef {
327            index: *index as u32,
328        },
329        OriginalItem::Cookware { index } => Item::CookwareRef {
330            index: *index as u32,
331        },
332        OriginalItem::Timer { index } => Item::TimerRef {
333            index: *index as u32,
334        },
335        // returning an empty block of text as it's not supported by the spec
336        OriginalItem::InlineQuantity { index: _ } => Item::Text {
337            value: "".to_string(),
338        },
339    }
340}
341
342pub(crate) fn into_simple_recipe(recipe: &OriginalRecipe) -> CooklangRecipe {
343    let mut metadata = CooklangMetadata::new();
344    let ingredients: Vec<Ingredient> = recipe.ingredients.iter().map(|i| i.into()).collect();
345    let cookware: Vec<Cookware> = recipe.cookware.iter().map(|i| i.into()).collect();
346    let timers: Vec<Timer> = recipe.timers.iter().map(|i| i.into()).collect();
347    let mut sections: Vec<Section> = Vec::new();
348
349    // Process each section
350    for section in &recipe.sections {
351        let mut blocks: Vec<Block> = Vec::new();
352
353        let mut ingredient_refs: Vec<u32> = Vec::new();
354        let mut cookware_refs: Vec<u32> = Vec::new();
355        let mut timer_refs: Vec<u32> = Vec::new();
356
357        // Process content within each section
358        for content in &section.content {
359            match content {
360                cooklang::Content::Step(step) => {
361                    let mut step_ingredient_refs: Vec<u32> = Vec::new();
362                    let mut step_cookware_refs: Vec<u32> = Vec::new();
363                    let mut step_timer_refs: Vec<u32> = Vec::new();
364
365                    let mut items: Vec<Item> = Vec::new();
366                    // Process step items
367                    for item in &step.items {
368                        let item = into_item(item);
369
370                        // Handle ingredients and cookware tracking
371                        match &item {
372                            Item::IngredientRef { index } => {
373                                step_ingredient_refs.push(*index);
374                            }
375                            Item::CookwareRef { index } => {
376                                step_cookware_refs.push(*index);
377                            }
378                            Item::TimerRef { index } => {
379                                step_timer_refs.push(*index);
380                            }
381                            _ => (),
382                        };
383                        items.push(item);
384                    }
385                    blocks.push(Block::StepBlock(Step {
386                        items,
387                        ingredient_refs: step_ingredient_refs.clone(),
388                        cookware_refs: step_cookware_refs.clone(),
389                        timer_refs: step_timer_refs.clone(),
390                    }));
391                    ingredient_refs.extend(step_ingredient_refs);
392                    cookware_refs.extend(step_cookware_refs);
393                    timer_refs.extend(step_timer_refs);
394                }
395
396                cooklang::Content::Text(text) => {
397                    blocks.push(Block::NoteBlock(BlockNote {
398                        text: text.to_string(),
399                    }));
400                }
401            }
402        }
403
404        sections.push(Section {
405            title: section.name.clone(),
406            blocks,
407            ingredient_refs,
408            cookware_refs,
409            timer_refs,
410        });
411    }
412
413    // Process metadata
414    // TODO: add support for nested metadata
415    for (key, value) in &recipe.metadata.map {
416        if let (Some(key), Some(value)) = (key.as_str(), value.as_str()) {
417            metadata.insert(key.to_string(), value.to_string());
418        }
419    }
420
421    CooklangRecipe {
422        metadata,
423        sections,
424        ingredients,
425        cookware,
426        timers,
427    }
428}
429
430impl From<&cooklang::Ingredient<OriginalScalableValue>> for Ingredient {
431    fn from(ingredient: &cooklang::Ingredient<OriginalScalableValue>) -> Self {
432        Ingredient {
433            name: ingredient.name.clone(),
434            amount: ingredient.quantity.as_ref().map(|q| q.extract_amount()),
435            descriptor: ingredient.note.clone(),
436        }
437    }
438}
439
440impl From<&cooklang::Cookware<OriginalScalableValue>> for Cookware {
441    fn from(cookware: &cooklang::Cookware<OriginalScalableValue>) -> Self {
442        Cookware {
443            name: cookware.name.clone(),
444            amount: cookware.quantity.as_ref().map(|q| q.extract_amount()),
445        }
446    }
447}
448
449impl From<&cooklang::Timer<OriginalScalableValue>> for Timer {
450    fn from(timer: &cooklang::Timer<OriginalScalableValue>) -> Self {
451        Timer {
452            name: Some(timer.name.clone().unwrap_or_default()),
453            amount: timer.quantity.as_ref().map(|q| q.extract_amount()),
454        }
455    }
456}