touchportal_sdk/
actions.rs

1use derive_builder::Builder;
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4
5/// Actions are one of the core components of Touch Portal. As a plug-in developer you can define
6/// actions for your plug-in that the user can add to their flow of actions in their buttons and
7/// events. An action is part of a [`Category`].
8///
9/// # Example actions
10///
11/// Below, you can see examples of both static and dynamic actions. Static Actions are actions that
12/// can be run without communication. Touch Portal allows communication between the plugin and
13/// Touch Portal but for some actions this is not required. For these situations you can use the
14/// static actions. This allows for developers to create plugins easier without to much hassle.
15/// With dynamic actions you are required to set up and run an application or service that
16/// communicates with Touch Portal. With static actions you can use commandline execution that will
17/// run directly from Touch Portal.
18///
19/// ## Static action
20///
21/// This shows a JSON of an action that does not require communication with a plugin application
22/// (static). The action is set up to use powershell to create a beep sound when it is executed.
23///
24/// ```
25/// use touchportal_sdk::{
26///   Action,
27///   ActionImplementation,
28///   Line,
29///   Lines,
30///   LingualLine,
31///   StaticAction,
32/// };
33///
34/// Action::builder()
35///     .id("tp_pl_action_001")
36///     .name("Execute action")
37///     .implementation(ActionImplementation::Static(
38///         StaticAction::builder()
39///             .execution_cmd("powershell [console]::beep(200,500)")
40///             .build()?
41///     ))
42///     .lines(
43///         Lines::builder()
44///             .action(
45///                 LingualLine::builder()
46///                     .datum(
47///                         Line::builder()
48///                             .line_format("Play Beep Sound")
49///                             .build()?
50///                     )
51///                     .build()?
52///             )
53///             .build()?
54///     )
55///     .build()?;
56/// # Ok::<_, Box<dyn std::error::Error>>(())
57/// ```
58///
59/// ## Dynamic action
60///
61/// This shows a JSON of an action that does require communication with a plugin application
62/// (dynamic). When executed, it will send the information the user has entered in the text field
63/// with id (tp_pl_002_text) to the plug-in. Touch Portal will parse the id from the format line
64/// and will present the user with the given control to allow for user input.
65///
66/// ```
67/// use touchportal_sdk::{
68///   Action,
69///   ActionImplementation,
70///   Data,
71///   DataFormat,
72///   Line,
73///   Lines,
74///   LingualLine,
75///   TextData,
76/// };
77///
78/// Action::builder()
79///     .id("tp_pl_action_002")
80///     .name("Execute Dynamic Action")
81///     .implementation(ActionImplementation::Dynamic)
82///     .datum(
83///         Data::builder()
84///             .id("tp_pl_002_text")
85///             .format(DataFormat::Text(
86///                 TextData::builder().build()?
87///             ))
88///             .build()?
89///     )
90///     .lines(
91///         Lines::builder()
92///             .action(
93///                 LingualLine::builder()
94///                     .datum(
95///                         Line::builder()
96///                             .line_format("Do something with value {$tp_pl_002_text$}")
97///                             .build()?
98///                     )
99///                     .build()?
100///             )
101///             .build()?
102///     )
103///     .build()?;
104/// # Ok::<_, Box<dyn std::error::Error>>(())
105/// ```
106///
107/// ## Multi-line action with multiple languages
108///
109/// ```
110/// use touchportal_sdk::{
111///   Action,
112///   ActionImplementation,
113///   Data,
114///   DataFormat,
115///   I18nNames,
116///   Line,
117///   Lines,
118///   LingualLine,
119///   TextData,
120/// };
121///
122/// Action::builder()
123///     .id("tp_pl_action_002")
124///     .name("Do something")
125///     .translated_names(
126///         I18nNames::builder()
127///             .dutch("Doe iets")
128///             .build()?
129///     )
130///     .implementation(ActionImplementation::Dynamic)
131///     .datum(
132///         Data::builder()
133///             .id("tp_pl_002_text")
134///             .format(DataFormat::Text(
135///                 TextData::builder().build()?
136///             ))
137///             .build()?
138///     )
139///     .lines(
140///         Lines::builder()
141///             .action(
142///                 LingualLine::builder()
143///                     .datum(
144///                         Line::builder()
145///                             .line_format("This actions shows multiple lines;")
146///                             .build()?
147///                     )
148///                     .datum(
149///                         Line::builder()
150///                             .line_format("Do something with value {$tp_pl_002_text$}")
151///                             .build()?
152///                     )
153///                     .build()?
154///             )
155///             .action(
156///                 LingualLine::builder()
157///                     .language("nl")
158///                     .datum(
159///                         Line::builder()
160///                             .line_format("Deze actie bevat meerdere regels;")
161///                             .build()?
162///                     )
163///                     .datum(
164///                         Line::builder()
165///                             .line_format("Doe iets met waarde {$tp_pl_002_text$}")
166///                             .build()?
167///                     )
168///                     .build()?
169///             )
170///             .build()?
171///     )
172///     .build()?;
173/// # Ok::<_, Box<dyn std::error::Error>>(())
174/// ```
175///
176/// Please note: when a user adds an action belonging to a plugin, it will create a local copy of
177/// the action and saves it along with the action. This means that if you change something in your
178/// action the users need to remove their instance of that action and re-add it to be able to use
179/// the new additions.
180#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
181#[builder(build_fn(validate = "Self::validate"))]
182#[serde(rename_all = "camelCase")]
183pub struct Action {
184    /// This is the id of the action.
185    ///
186    /// It is used to identify the action within Touch Portal. This id needs to be unique across
187    /// plugins. This means that if you give it the id "1" there is a big chance that it will be a
188    /// duplicate. Touch Portal may reject it or when the other action is called, yours will be as
189    /// well with wrong data. Best practice is to create a unique prefix for all your actions like
190    /// in our case; `tp_pl_action_001`.
191    #[builder(setter(into))]
192    pub(crate) id: String,
193
194    /// This is the name of the action.
195    ///
196    /// This will be used as the action name in the action category list.
197    #[builder(setter(into))]
198    pub(crate) name: String,
199
200    #[serde(flatten)]
201    #[builder(default)]
202    translated_names: I18nNames,
203
204    /// This is the attribute that specifies whether this is a static action "execute" or a dynamic
205    /// action "communicate".
206    #[serde(flatten)]
207    pub(crate) implementation: ActionImplementation,
208
209    /// This is a collection of action data (see definition further down this page) which can be
210    /// specified by the user.
211    ///
212    /// These data id's can be used to fill up the `execution_cmd` text or the format (see example
213    /// on the right side).
214    #[builder(setter(each(name = "datum")), default)]
215    pub(crate) data: Vec<super::data::Data>,
216
217    /// This is the object for specifying the action and/or onhold lines.
218    lines: Lines,
219
220    /// This attribute allows you to connect this action to a specified subcategory id.
221    ///
222    /// This action will then be shown in Touch Portals Action selection list attached to that
223    /// subcategory instead of the main parent category.
224    #[builder(setter(into, strip_option), default)]
225    #[serde(skip_serializing_if = "Option::is_none")]
226    sub_category_id: Option<String>,
227}
228
229impl Action {
230    pub fn builder() -> ActionBuilder {
231        ActionBuilder::default()
232    }
233}
234
235impl ActionBuilder {
236    fn validate(&self) -> Result<(), String> {
237        // Check for empty required fields
238        let name = self.name.as_ref().expect("name is required");
239        if name.trim().is_empty() {
240            return Err("action name cannot be empty".to_string());
241        }
242
243        let id = self.id.as_ref().expect("id is required");
244        if id.trim().is_empty() {
245            return Err("action id cannot be empty".to_string());
246        }
247
248        let mut data_ids = HashSet::new();
249        for data in self.data.iter().flatten() {
250            data_ids.insert(format!("{{${}$}}", data.id));
251
252            if let crate::DataFormat::Choice(def) = &data.format
253                && !def.value_choices.contains(&def.initial)
254            {
255                return Err(format!(
256                    "initial value {} is not among valid choices {:?}",
257                    def.initial, def.value_choices
258                ));
259            }
260        }
261
262        let lines = self.lines.as_ref().expect("lines is required");
263        let mut languages = HashSet::new();
264        for line in &lines.actions {
265            if !languages.insert(&line.language) {
266                return Err(format!("found two lines for language '{}'", line.language));
267            }
268        }
269
270        for line in &lines.actions {
271            for data_id in &data_ids {
272                if !line
273                    .data
274                    .iter()
275                    .any(|line| line.line_format.contains(&**data_id))
276                {
277                    return Err(format!(
278                        "'{}' not found for language '{}'",
279                        data_id, line.language
280                    ));
281                }
282            }
283        }
284
285        Ok(())
286    }
287}
288
289#[derive(Debug, Clone, Deserialize, Serialize)]
290#[serde(tag = "type")]
291#[non_exhaustive]
292pub enum ActionImplementation {
293    #[serde(rename = "execute")]
294    #[doc(alias = "execute")]
295    Static(StaticAction),
296
297    #[serde(rename = "communicate")]
298    #[doc(alias = "communicate")]
299    Dynamic,
300}
301
302#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
303#[serde(rename_all = "camelCase")]
304pub struct StaticAction {
305    /// This is the attribute that specifies what kind of execution this action should use.
306    ///
307    /// This a Mac only functionality.
308    #[cfg(target_os = "macos")]
309    #[serde(skip_serializing_if = "Option::is_none")]
310    #[builder(setter(into, skip_option), default)]
311    execution_type: Option<ExecutionType>,
312
313    /// Specify the path of execution here.
314    ///
315    /// You should be aware that it will be passed to the OS process exection service. This means
316    /// you need to be aware of spaces and use absolute paths to your executable.
317    ///
318    /// If you use `%TP_PLUGIN_FOLDER%` in the text here, it will be replaced with the path to the
319    /// base plugin folder.
320    #[serde(rename = "execution_cmd")]
321    #[builder(setter(into))]
322    execution_cmd: String,
323}
324
325impl StaticAction {
326    pub fn builder() -> StaticActionBuilder {
327        StaticActionBuilder::default()
328    }
329}
330
331#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
332#[non_exhaustive]
333pub enum ExecutionType {
334    AppleScript,
335    Bash,
336}
337
338/// Language specific version of a name.
339#[derive(Debug, Clone, Builder, Deserialize, Serialize, Default)]
340pub struct I18nNames {
341    #[serde(rename = "name_nl")]
342    #[serde(skip_serializing_if = "Option::is_none")]
343    #[builder(setter(into, strip_option), default)]
344    dutch: Option<String>,
345
346    #[serde(rename = "name_de")]
347    #[serde(skip_serializing_if = "Option::is_none")]
348    #[builder(setter(into, strip_option), default)]
349    german: Option<String>,
350
351    #[serde(rename = "name_es")]
352    #[serde(skip_serializing_if = "Option::is_none")]
353    #[builder(setter(into, strip_option), default)]
354    spanish: Option<String>,
355
356    #[serde(rename = "name_fr")]
357    #[serde(skip_serializing_if = "Option::is_none")]
358    #[builder(setter(into, strip_option), default)]
359    french: Option<String>,
360
361    #[serde(rename = "name_pt")]
362    #[serde(skip_serializing_if = "Option::is_none")]
363    #[builder(setter(into, strip_option), default)]
364    portugese: Option<String>,
365
366    #[serde(rename = "name_tr")]
367    #[serde(skip_serializing_if = "Option::is_none")]
368    #[builder(setter(into, strip_option), default)]
369    turkish: Option<String>,
370}
371
372impl I18nNames {
373    pub fn builder() -> I18nNamesBuilder {
374        I18nNamesBuilder::default()
375    }
376}
377
378/// The lines object consist of the parts; the action lines and the onhold lines.
379///
380/// You can specify either or both.
381///
382/// Those arrays then consist of lines information per supported language.
383#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
384#[builder(build_fn(validate = "Self::validate"))]
385#[serde(rename_all = "camelCase")]
386pub struct Lines {
387    #[serde(rename = "action")]
388    #[serde(skip_serializing_if = "Vec::is_empty")]
389    #[builder(setter(each(name = "action")), default)]
390    actions: Vec<LingualLine>,
391
392    #[serde(rename = "onhold")]
393    #[serde(skip_serializing_if = "Vec::is_empty")]
394    #[builder(setter(each(name = "onhold")), default)]
395    onholds: Vec<LingualLine>,
396}
397
398impl Lines {
399    pub fn builder() -> LinesBuilder {
400        LinesBuilder::default()
401    }
402}
403
404impl LinesBuilder {
405    fn validate(&self) -> Result<(), String> {
406        if self.actions.as_ref().is_none_or(|a| a.is_empty())
407            && self.onholds.as_ref().is_none_or(|o| o.is_empty())
408        {
409            return Err("At least one action or onhold must be set".to_string());
410        }
411
412        if self.actions.as_ref().is_some_and(|a| !a.is_empty())
413            && !self
414                .actions
415                .iter()
416                .flatten()
417                .any(|line| line.language == "default")
418        {
419            return Err("The default language must be present among the action lines".to_string());
420        }
421
422        if self.onholds.as_ref().is_some_and(|a| !a.is_empty())
423            && !self
424                .onholds
425                .iter()
426                .flatten()
427                .any(|line| line.language == "default")
428        {
429            return Err("The default language must be present among the onhold lines".to_string());
430        }
431
432        Ok(())
433    }
434}
435
436#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
437#[builder(build_fn(validate = "Self::validate"))]
438#[serde(rename_all = "camelCase")]
439pub struct LingualLine {
440    /// This is the country code of the language this line information contains.
441    ///
442    /// Use the default for the English language. The default should always be present. If it is
443    /// not, the lines will not be rendered in Touch Portal even if you have language specific
444    /// lines.
445    #[builder(setter(into), default = "String::from(\"default\")")]
446    language: String,
447
448    /// This is the array of line objects representing the lines of the action.
449    ///
450    /// This array should have at least 1 entry.
451    ///
452    /// We suggest to not use more than 3 lines to keep action lists clean and clear. Use a maximum
453    /// of 8 lines in your actions as that will reduce the usability for the end user as the
454    /// actions might get to big on smaller screens to properly view and scroll.
455    #[builder(setter(each(name = "datum")))]
456    data: Vec<Line>,
457
458    #[builder(setter(into, strip_option), default)]
459    #[serde(skip_serializing_if = "Option::is_none")]
460    suggestions: Option<Suggestions>,
461}
462
463impl LingualLine {
464    pub fn builder() -> LingualLineBuilder {
465        LingualLineBuilder::default()
466    }
467}
468
469impl LingualLineBuilder {
470    fn validate(&self) -> Result<(), String> {
471        let data = self.data.as_ref().expect("data is required");
472        if data.is_empty() {
473            return Err("At least one line object must be set".to_string());
474        }
475
476        // Check TouchPortal recommended maximum of 8 lines per action
477        if data.len() > 8 {
478            return Err(format!(
479                "action has {} lines, but TouchPortal recommends \
480                a maximum of 8 lines for proper visibility \
481                on smaller screens",
482                data.len()
483            ));
484        }
485
486        Ok(())
487    }
488}
489
490#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
491#[serde(rename_all = "camelCase")]
492pub struct Line {
493    /// This will be the format of the rendered line in the action.
494    ///
495    /// Use the id's of the data objects to place them within the text, such as:
496    ///
497    /// ```ignore
498    /// "When {$actiondata001$} has {$actiondata002$} and number {$actiondata003$} is {$actiondata004$}"
499    /// ```
500    ///
501    /// This is a fictive form but it shows how to use this. The data object with the id
502    /// `actiondata001` will be shown at the given location. To have an data object appear on the
503    /// action line, use the format `{$id$}` where id is the id of the data object you want to show
504    /// the control for.
505    #[builder(setter(into))]
506    line_format: String,
507}
508
509impl Line {
510    pub fn builder() -> LineBuilder {
511        LineBuilder::default()
512    }
513}
514
515/// This is a suggestions object where you can specify certain rendering behaviours of the action
516/// lines.
517///
518/// These are suggestions and my be overruled in certain situations in Touch Portal. One example is
519/// rendering lines for different action rendering themes in Touch Portal.
520#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
521#[serde(rename_all = "camelCase")]
522pub struct Suggestions {
523    /// This option will set the width of the first part on a line if it is text.
524    ///
525    /// This can be used to make your action more clear for our users. This can be usefull when you
526    /// list one item to set per line.
527    #[serde(skip_serializing_if = "Option::is_none")]
528    #[builder(setter(strip_option), default)]
529    first_line_item_label_width: Option<u32>,
530
531    /// This option will add padding on the left for each line of a multiline format.
532    ///
533    /// If this is used together with `first_line_item_label_width`, the padding will be part of
534    /// the that width and will not be added onto it.
535    #[serde(skip_serializing_if = "Option::is_none")]
536    #[builder(setter(strip_option), default)]
537    line_indentation: Option<u32>,
538}
539
540impl Suggestions {
541    pub fn builder() -> SuggestionsBuilder {
542        SuggestionsBuilder::default()
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::{Data, DataFormat, TextData};
550
551    #[test]
552    fn serialize_tutorial_sdk_plugin_static_action() {
553        assert_eq!(
554            serde_json::to_value(
555                Action::builder()
556                    .id("tp_pl_action_001")
557                    .name("Execute action")
558                    .implementation(ActionImplementation::Static(
559                        StaticAction::builder()
560                            .execution_cmd("powershell [console]::beep(200,500)")
561                            .build()
562                            .unwrap()
563                    ))
564                    .lines(
565                        Lines::builder()
566                            .action(
567                                LingualLine::builder()
568                                    .datum(
569                                        Line::builder()
570                                            .line_format("Play Beep Sound")
571                                            .build()
572                                            .unwrap()
573                                    )
574                                    .build()
575                                    .unwrap()
576                            )
577                            .build()
578                            .unwrap()
579                    )
580                    .build()
581                    .unwrap()
582            )
583            .unwrap(),
584            serde_json::json! {{
585              "id":"tp_pl_action_001",
586              "name":"Execute action",
587              "type":"execute",
588              "lines": {
589                "action": [
590                  {
591                    "language": "default",
592                    "data" : [
593                      {
594                        "lineFormat":"Play Beep Sound"
595                      }
596                    ]
597                  }
598                ]
599              },
600              "execution_cmd":"powershell [console]::beep(200,500)",
601              "data":[ ]
602            } }
603        );
604    }
605
606    #[test]
607    fn serialize_tutorial_sdk_plugin_dynamic_action() {
608        assert_eq!(
609            serde_json::to_value(
610                Action::builder()
611                    .id("tp_pl_action_002")
612                    .name("Execute Dynamic Action")
613                    .implementation(ActionImplementation::Dynamic)
614                    .datum(
615                        Data::builder()
616                            .id("tp_pl_002_text")
617                            .format(DataFormat::Text(TextData::builder().build().unwrap()))
618                            .build()
619                            .unwrap()
620                    )
621                    .lines(
622                        Lines::builder()
623                            .action(
624                                LingualLine::builder()
625                                    .datum(
626                                        Line::builder()
627                                            .line_format(
628                                                "Do something with value {$tp_pl_002_text$}"
629                                            )
630                                            .build()
631                                            .unwrap()
632                                    )
633                                    .build()
634                                    .unwrap()
635                            )
636                            .build()
637                            .unwrap()
638                    )
639                    .build()
640                    .unwrap()
641            )
642            .unwrap(),
643            serde_json::json! {{
644              "id": "tp_pl_action_002",
645              "name": "Execute Dynamic Action",
646              "lines": {
647                "action": [
648                  {
649                    "language": "default",
650                    "data" : [
651                      {
652                        "lineFormat":"Do something with value {$tp_pl_002_text$}"
653                      }
654                    ]
655                  }
656                ]
657              },
658              "type": "communicate",
659              "data": [
660                {
661                  "type": "text",
662                  "default": "",
663                  "id": "tp_pl_002_text"
664                }
665              ]
666            } }
667        );
668    }
669
670    #[test]
671    fn serialize_tutorial_sdk_plugin_multi_lang_action() {
672        assert_eq!(
673            serde_json::to_value(
674                Action::builder()
675                    .id("tp_pl_action_002")
676                    .name("Do something")
677                    .translated_names(I18nNames::builder().dutch("Doe iets").build().unwrap())
678                    .implementation(ActionImplementation::Dynamic)
679                    .datum(
680                        Data::builder()
681                            .id("tp_pl_002_text")
682                            .format(DataFormat::Text(TextData::builder().build().unwrap()))
683                            .build()
684                            .unwrap()
685                    )
686                    .lines(
687                        Lines::builder()
688                            .action(
689                                LingualLine::builder()
690                                    .datum(
691                                        Line::builder()
692                                            .line_format("This actions shows multiple lines;")
693                                            .build()
694                                            .unwrap()
695                                    )
696                                    .datum(
697                                        Line::builder()
698                                            .line_format(
699                                                "Do something with value {$tp_pl_002_text$}"
700                                            )
701                                            .build()
702                                            .unwrap()
703                                    )
704                                    .build()
705                                    .unwrap()
706                            )
707                            .action(
708                                LingualLine::builder()
709                                    .language("nl")
710                                    .datum(
711                                        Line::builder()
712                                            .line_format("Deze actie bevat meerdere regels;")
713                                            .build()
714                                            .unwrap()
715                                    )
716                                    .datum(
717                                        Line::builder()
718                                            .line_format("Doe iets met waarde {$tp_pl_002_text$}")
719                                            .build()
720                                            .unwrap()
721                                    )
722                                    .build()
723                                    .unwrap()
724                            )
725                            .build()
726                            .unwrap()
727                    )
728                    .build()
729                    .unwrap()
730            )
731            .unwrap(),
732            serde_json::json! {{
733              "id": "tp_pl_action_002",
734              "name": "Do something",
735              "name_nl": "Doe iets",
736              "lines": {
737                "action": [
738                  {
739                    "language": "default",
740                    "data" : [
741                      {
742                        "lineFormat":"This actions shows multiple lines;",
743                      },
744                      {
745                        "lineFormat":"Do something with value {$tp_pl_002_text$}"
746                      }
747                    ]
748                  },
749                  {
750                    "language": "nl",
751                    "data" : [
752                      {
753                        "lineFormat":"Deze actie bevat meerdere regels;",
754                      },
755                      {
756                        "lineFormat":"Doe iets met waarde {$tp_pl_002_text$}"
757                      }
758                    ]
759                  }
760                ]
761              },
762              "type": "communicate",
763              "data": [
764                {
765                  "type": "text",
766                  "default": "",
767                  "id": "tp_pl_002_text"
768                }
769              ]
770            }}
771        );
772    }
773}