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}