shared/domain/module/body/_groups/
cards.rs

1/*
2 * The card modules not only share some base content
3 * But the editor steps are identical except for 3
4 */
5use crate::{
6    config,
7    domain::module::body::{Audio, Background, Image, ModeExt, ModuleAssist, StepExt, ThemeId},
8};
9use serde::{de, Deserialize, Serialize};
10use std::collections::HashSet;
11use std::fmt;
12use unicode_segmentation::UnicodeSegmentation;
13
14/// The base content for card modules
15#[derive(Default, Clone, Serialize, Deserialize, Debug)]
16pub struct BaseContent {
17    /// The editor state
18    pub editor_state: EditorState,
19
20    /// The instructions for the module.
21    pub instructions: ModuleAssist,
22
23    /// The feedback for the module.
24    #[serde(default)]
25    pub feedback: ModuleAssist,
26
27    /// The mode the module uses.
28    pub mode: Mode,
29
30    /// The pairs of cards that make up the module.
31    pub pairs: Vec<CardPair>,
32
33    /// The ID of the module's theme.
34    pub theme: ThemeId,
35
36    /// The optional background override
37    pub background: Option<Background>,
38}
39
40impl BaseContent {
41    /// Get a new BaseContent
42    pub fn new(mode: Mode) -> Self {
43        Self {
44            mode,
45            ..Self::default()
46        }
47    }
48
49    /// Convenience method to determine whether pairs have been configured correctly
50    pub fn is_valid(&self) -> bool {
51        let pair_len = self.pairs.len();
52        pair_len >= config::MIN_LIST_WORDS && self.mode.pairs_valid(&self.pairs)
53    }
54}
55
56/// Editor state
57#[derive(Default, Clone, Serialize, Deserialize, Debug)]
58pub struct EditorState {
59    /// the current step
60    pub step: Step,
61
62    /// the completed steps
63    pub steps_completed: HashSet<Step>,
64}
65
66/// A pair of cards
67#[derive(Clone, Serialize, Deserialize, Debug)]
68pub struct CardPair(pub Card, pub Card);
69
70/// Data for individual cards
71#[derive(Clone, Serialize, Debug)]
72pub struct Card {
73    /// Recorded audio associated with the card
74    pub audio: Option<Audio>,
75
76    /// Content associated with the card
77    pub card_content: CardContent,
78}
79
80/// Util fn to fetch the length of a cards text if its content is text
81pub fn get_card_text_length(card: &Card) -> usize {
82    match &card.card_content {
83        CardContent::Text(text) => text.graphemes(true).count(),
84        _ => 0,
85    }
86}
87
88/// Util fn to fetch the longest card text in a list of card pair
89pub fn get_longest_card_text_length<'c>(cards: impl Iterator<Item = &'c Card>) -> usize {
90    cards.fold(0, |acc, card| {
91        let longest_current = match &card.card_content {
92            CardContent::Text(a) => a.graphemes(true).count(),
93            _ => 0,
94        };
95
96        acc.max(longest_current)
97    })
98}
99
100// Required because we need to be able to handle the data for the original Card enum, and also data
101// from the new Card struct.
102//
103// I.e. converts from
104//
105// [{"Text": "Some words"}, {"Image": {<Image data>}}]
106//
107// to
108//
109// [{
110//   audio: null,
111//   card_content: {"Text": "Some words"}
112// }, {
113//   audio: null,
114//   card_content: {"Image": {<Image data>}}
115// }]
116//
117// TODO Create a content migration to migrate all existing JIGs with card game modules so that
118// their card data matches the new Card struct and delete this Deserialize implementation.
119impl<'de> de::Deserialize<'de> for Card {
120    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121    where
122        D: de::Deserializer<'de>,
123    {
124        #[derive(Debug, Deserialize)]
125        #[serde(field_identifier)]
126        enum CardField {
127            #[serde(rename = "audio")]
128            Audio,
129            #[serde(rename = "card_content")]
130            CardContent,
131            #[serde(rename = "Text")]
132            Text,
133            #[serde(rename = "Image")]
134            Image,
135        }
136
137        struct CardVisitor;
138
139        impl<'de> de::Visitor<'de> for CardVisitor {
140            type Value = Card;
141
142            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143                formatter.write_str("A CardContent or Card map")
144            }
145
146            fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
147            where
148                M: de::MapAccess<'de>,
149            {
150                let mut audio: Option<Option<Audio>> = None;
151                let mut card_content: Option<CardContent> = None;
152
153                while let Some(key) = access.next_key()? {
154                    match key {
155                        CardField::Text => {
156                            if card_content.is_some() {
157                                return Err(de::Error::duplicate_field("card_content"));
158                            }
159                            card_content = Some(CardContent::Text(access.next_value()?));
160                            break;
161                        }
162                        CardField::Image => {
163                            if card_content.is_some() {
164                                return Err(de::Error::duplicate_field("card_content"));
165                            }
166                            card_content = Some(CardContent::Image(access.next_value()?));
167                            break;
168                        }
169                        CardField::Audio => {
170                            if audio.is_some() {
171                                return Err(de::Error::duplicate_field("audio"));
172                            }
173                            audio = Some(access.next_value()?);
174                        }
175                        CardField::CardContent => {
176                            if card_content.is_some() {
177                                return Err(de::Error::duplicate_field("card_content"));
178                            }
179                            card_content = Some(access.next_value()?);
180                        }
181                    }
182                }
183
184                let audio = audio.map_or(None, |audio| audio);
185                let card_content =
186                    card_content.ok_or_else(|| de::Error::missing_field("card_content"))?;
187
188                Ok(Card {
189                    audio,
190                    card_content,
191                })
192            }
193        }
194
195        deserializer.deserialize_map(CardVisitor)
196    }
197}
198
199/// The content of a card
200#[derive(Clone, Serialize, Deserialize, Debug)]
201pub enum CardContent {
202    // todo(@dakom): document this
203    #[allow(missing_docs)]
204    Text(String),
205
206    // todo(@dakom): document this
207    #[allow(missing_docs)]
208    Image(Option<Image>),
209}
210
211impl Card {
212    /// Whether the variants value is empty
213    pub fn is_empty(&self) -> bool {
214        match &self.card_content {
215            CardContent::Text(value) if value.trim().len() == 0 => true,
216            CardContent::Image(None) => true,
217            _ => false,
218        }
219    }
220}
221
222/// What mode the module runs in.
223#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)]
224#[repr(i16)]
225#[cfg_attr(feature = "backend", derive(sqlx::Type))]
226pub enum Mode {
227    // todo(@dakom): document this
228    #[allow(missing_docs)]
229    Duplicate = 0,
230
231    // todo(@dakom): document this
232    #[allow(missing_docs)]
233    WordsAndImages = 1,
234
235    // todo(@dakom): document this
236    #[allow(missing_docs)]
237    BeginsWith = 2,
238
239    // todo(@dakom): document this
240    #[allow(missing_docs)]
241    Lettering = 3,
242
243    // todo(@dakom): document this
244    #[allow(missing_docs)]
245    Riddles = 4,
246
247    // todo(@dakom): document this
248    #[allow(missing_docs)]
249    Opposites = 5,
250
251    // todo(@dakom): document this
252    #[allow(missing_docs)]
253    Synonyms = 6,
254
255    /// Translate from one language to another
256    Translate = 7,
257
258    /// Pairs of cards with images only
259    Images = 8,
260}
261
262impl Mode {
263    /// Returns whether a list of card pairs are valid for the game mode
264    pub fn pairs_valid(&self, pairs: &Vec<CardPair>) -> bool {
265        match self {
266            // Text/Image pairs
267            Self::WordsAndImages => {
268                pairs
269                    .iter()
270                    .find(|pair| {
271                        // Neither card should be empty; the first card should be a Text variant and
272                        // the 2nd card should be an Image variant.
273                        pair.0.is_empty()
274                            || pair.1.is_empty()
275                            || !matches!(pair.0.card_content, CardContent::Text(_))
276                            || !matches!(pair.1.card_content, CardContent::Image(_))
277                    })
278                    .is_none()
279            }
280            // Image/Image pairs
281            Self::Images => {
282                pairs
283                    .iter()
284                    .find(|pair| {
285                        // Neither card should be empty, and both cards must be Image variants.
286                        pair.0.is_empty()
287                            || pair.1.is_empty()
288                            || !matches!(pair.0.card_content, CardContent::Image(_))
289                            || !matches!(pair.1.card_content, CardContent::Image(_))
290                    })
291                    .is_none()
292            }
293            // Text/Text pairs
294            _ => {
295                pairs
296                    .iter()
297                    .find(|pair| {
298                        // Neither card should be empty, and both cards must be Text variants.
299                        pair.0.is_empty()
300                            || pair.1.is_empty()
301                            || !matches!(pair.0.card_content, CardContent::Text(_))
302                            || !matches!(pair.1.card_content, CardContent::Text(_))
303                    })
304                    .is_none()
305            }
306        }
307    }
308}
309
310impl Default for Mode {
311    fn default() -> Self {
312        Self::Duplicate
313    }
314}
315
316impl ModeExt for Mode {
317    fn get_list() -> Vec<Self> {
318        vec![
319            Self::Duplicate,
320            Self::WordsAndImages,
321            Self::Lettering,
322            Self::Images,
323            Self::BeginsWith,
324            Self::Riddles,
325            Self::Opposites,
326            Self::Synonyms,
327            Self::Translate,
328        ]
329    }
330
331    fn as_str_id(&self) -> &'static str {
332        match self {
333            Self::Duplicate => "duplicate",
334            Self::WordsAndImages => "words-images",
335            Self::BeginsWith => "begins-with",
336            Self::Lettering => "lettering",
337            Self::Riddles => "riddles",
338            Self::Opposites => "opposites",
339            Self::Synonyms => "synonyms",
340            Self::Translate => "translate",
341            Self::Images => "images",
342        }
343    }
344
345    fn label(&self) -> &'static str {
346        const STR_DUPLICATE: &'static str = "Duplicate";
347        const STR_WORDS_IMAGES: &'static str = "Words & Images";
348        const STR_BEGINS_WITH: &'static str = "Begins with...";
349        const STR_LETTERING: &'static str = "Script & Print";
350        const STR_RIDDLES: &'static str = "Riddles";
351        const STR_OPPOSITES: &'static str = "Opposites";
352        const STR_SYNONYMS: &'static str = "Synonyms";
353        const STR_TRANSLATE: &'static str = "Translation";
354        const STR_IMAGES: &'static str = "Images";
355
356        match self {
357            Self::Duplicate => STR_DUPLICATE,
358            Self::WordsAndImages => STR_WORDS_IMAGES,
359            Self::BeginsWith => STR_BEGINS_WITH,
360            Self::Lettering => STR_LETTERING,
361            Self::Riddles => STR_RIDDLES,
362            Self::Opposites => STR_OPPOSITES,
363            Self::Synonyms => STR_SYNONYMS,
364            Self::Translate => STR_TRANSLATE,
365            Self::Images => STR_IMAGES,
366        }
367    }
368}
369
370/// The Steps
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
372pub enum Step {
373    /// Step 1
374    One,
375    /// Step 2
376    Two,
377    /// Step 3
378    Three,
379    /// Step 4
380    Four,
381}
382
383impl Default for Step {
384    fn default() -> Self {
385        Self::One
386    }
387}
388
389impl StepExt for Step {
390    fn next(&self) -> Option<Self> {
391        match self {
392            Self::One => Some(Self::Two),
393            Self::Two => Some(Self::Three),
394            Self::Three => Some(Self::Four),
395            Self::Four => None,
396        }
397    }
398
399    fn as_number(&self) -> usize {
400        match self {
401            Self::One => 1,
402            Self::Two => 2,
403            Self::Three => 3,
404            Self::Four => 4,
405        }
406    }
407
408    fn label(&self) -> &'static str {
409        //TODO - localizaton
410        const STR_CONTENT: &'static str = "Content";
411        const STR_DESIGN: &'static str = "Design";
412        const STR_SETTINGS: &'static str = "Settings";
413        const STR_PREVIEW: &'static str = "Preview";
414
415        match self {
416            Self::One => STR_CONTENT,
417            Self::Two => STR_DESIGN,
418            Self::Three => STR_SETTINGS,
419            Self::Four => STR_PREVIEW,
420        }
421    }
422
423    fn get_list() -> Vec<Self> {
424        vec![Self::One, Self::Two, Self::Three, Self::Four]
425    }
426    fn get_preview() -> Self {
427        Self::Four
428    }
429}