use derive_builder::Builder;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
#[builder(build_fn(validate = "Self::validate"))]
#[serde(rename_all = "camelCase")]
pub struct Action {
#[builder(setter(into))]
pub(crate) id: String,
#[builder(setter(into))]
pub(crate) name: String,
#[serde(flatten)]
#[builder(default)]
translated_names: I18nNames,
#[serde(flatten)]
pub(crate) implementation: ActionImplementation,
#[builder(setter(each(name = "datum")), default)]
pub(crate) data: Vec<super::data::Data>,
lines: Lines,
#[builder(setter(into, strip_option), default)]
#[serde(skip_serializing_if = "Option::is_none")]
sub_category_id: Option<String>,
}
impl Action {
pub fn builder() -> ActionBuilder {
ActionBuilder::default()
}
}
impl ActionBuilder {
fn validate(&self) -> Result<(), String> {
let name = self.name.as_ref().expect("name is required");
if name.trim().is_empty() {
return Err("action name cannot be empty".to_string());
}
let id = self.id.as_ref().expect("id is required");
if id.trim().is_empty() {
return Err("action id cannot be empty".to_string());
}
let mut data_ids = HashSet::new();
for data in self.data.iter().flatten() {
data_ids.insert(format!("{{${}$}}", data.id));
if let crate::DataFormat::Choice(def) = &data.format
&& !def.value_choices.contains(&def.initial)
{
return Err(format!(
"initial value {} is not among valid choices {:?}",
def.initial, def.value_choices
));
}
}
let lines = self.lines.as_ref().expect("lines is required");
let mut languages = HashSet::new();
for line in &lines.actions {
if !languages.insert(&line.language) {
return Err(format!("found two lines for language '{}'", line.language));
}
}
for line in &lines.actions {
for data_id in &data_ids {
if !line
.data
.iter()
.any(|line| line.line_format.contains(&**data_id))
{
return Err(format!(
"'{}' not found for language '{}'",
data_id, line.language
));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type")]
#[non_exhaustive]
pub enum ActionImplementation {
#[serde(rename = "execute")]
#[doc(alias = "execute")]
Static(StaticAction),
#[serde(rename = "communicate")]
#[doc(alias = "communicate")]
Dynamic,
}
#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StaticAction {
#[cfg(target_os = "macos")]
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(setter(into, skip_option), default)]
execution_type: Option<ExecutionType>,
#[serde(rename = "execution_cmd")]
#[builder(setter(into))]
execution_cmd: String,
}
impl StaticAction {
pub fn builder() -> StaticActionBuilder {
StaticActionBuilder::default()
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[non_exhaustive]
pub enum ExecutionType {
AppleScript,
Bash,
}
#[derive(Debug, Clone, Builder, Deserialize, Serialize, Default)]
pub struct I18nNames {
#[serde(rename = "name_nl")]
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(setter(into, strip_option), default)]
dutch: Option<String>,
#[serde(rename = "name_de")]
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(setter(into, strip_option), default)]
german: Option<String>,
#[serde(rename = "name_es")]
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(setter(into, strip_option), default)]
spanish: Option<String>,
#[serde(rename = "name_fr")]
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(setter(into, strip_option), default)]
french: Option<String>,
#[serde(rename = "name_pt")]
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(setter(into, strip_option), default)]
portugese: Option<String>,
#[serde(rename = "name_tr")]
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(setter(into, strip_option), default)]
turkish: Option<String>,
}
impl I18nNames {
pub fn builder() -> I18nNamesBuilder {
I18nNamesBuilder::default()
}
}
#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
#[builder(build_fn(validate = "Self::validate"))]
#[serde(rename_all = "camelCase")]
pub struct Lines {
#[serde(rename = "action")]
#[serde(skip_serializing_if = "Vec::is_empty")]
#[builder(setter(each(name = "action")), default)]
actions: Vec<LingualLine>,
#[serde(rename = "onhold")]
#[serde(skip_serializing_if = "Vec::is_empty")]
#[builder(setter(each(name = "onhold")), default)]
onholds: Vec<LingualLine>,
}
impl Lines {
pub fn builder() -> LinesBuilder {
LinesBuilder::default()
}
}
impl LinesBuilder {
fn validate(&self) -> Result<(), String> {
if self.actions.as_ref().is_none_or(|a| a.is_empty())
&& self.onholds.as_ref().is_none_or(|o| o.is_empty())
{
return Err("At least one action or onhold must be set".to_string());
}
if self.actions.as_ref().is_some_and(|a| !a.is_empty())
&& !self
.actions
.iter()
.flatten()
.any(|line| line.language == "default")
{
return Err("The default language must be present among the action lines".to_string());
}
if self.onholds.as_ref().is_some_and(|a| !a.is_empty())
&& !self
.onholds
.iter()
.flatten()
.any(|line| line.language == "default")
{
return Err("The default language must be present among the onhold lines".to_string());
}
Ok(())
}
}
#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
#[builder(build_fn(validate = "Self::validate"))]
#[serde(rename_all = "camelCase")]
pub struct LingualLine {
#[builder(setter(into), default = "String::from(\"default\")")]
language: String,
#[builder(setter(each(name = "datum")))]
data: Vec<Line>,
#[builder(setter(into, strip_option), default)]
#[serde(skip_serializing_if = "Option::is_none")]
suggestions: Option<Suggestions>,
}
impl LingualLine {
pub fn builder() -> LingualLineBuilder {
LingualLineBuilder::default()
}
}
impl LingualLineBuilder {
fn validate(&self) -> Result<(), String> {
let data = self.data.as_ref().expect("data is required");
if data.is_empty() {
return Err("At least one line object must be set".to_string());
}
if data.len() > 8 {
return Err(format!(
"action has {} lines, but TouchPortal recommends \
a maximum of 8 lines for proper visibility \
on smaller screens",
data.len()
));
}
Ok(())
}
}
#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Line {
#[builder(setter(into))]
line_format: String,
}
impl Line {
pub fn builder() -> LineBuilder {
LineBuilder::default()
}
}
#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Suggestions {
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(setter(strip_option), default)]
first_line_item_label_width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(setter(strip_option), default)]
line_indentation: Option<u32>,
}
impl Suggestions {
pub fn builder() -> SuggestionsBuilder {
SuggestionsBuilder::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Data, DataFormat, TextData};
#[test]
fn serialize_tutorial_sdk_plugin_static_action() {
assert_eq!(
serde_json::to_value(
Action::builder()
.id("tp_pl_action_001")
.name("Execute action")
.implementation(ActionImplementation::Static(
StaticAction::builder()
.execution_cmd("powershell [console]::beep(200,500)")
.build()
.unwrap()
))
.lines(
Lines::builder()
.action(
LingualLine::builder()
.datum(
Line::builder()
.line_format("Play Beep Sound")
.build()
.unwrap()
)
.build()
.unwrap()
)
.build()
.unwrap()
)
.build()
.unwrap()
)
.unwrap(),
serde_json::json! {{
"id":"tp_pl_action_001",
"name":"Execute action",
"type":"execute",
"lines": {
"action": [
{
"language": "default",
"data" : [
{
"lineFormat":"Play Beep Sound"
}
]
}
]
},
"execution_cmd":"powershell [console]::beep(200,500)",
"data":[ ]
} }
);
}
#[test]
fn serialize_tutorial_sdk_plugin_dynamic_action() {
assert_eq!(
serde_json::to_value(
Action::builder()
.id("tp_pl_action_002")
.name("Execute Dynamic Action")
.implementation(ActionImplementation::Dynamic)
.datum(
Data::builder()
.id("tp_pl_002_text")
.format(DataFormat::Text(TextData::builder().build().unwrap()))
.build()
.unwrap()
)
.lines(
Lines::builder()
.action(
LingualLine::builder()
.datum(
Line::builder()
.line_format(
"Do something with value {$tp_pl_002_text$}"
)
.build()
.unwrap()
)
.build()
.unwrap()
)
.build()
.unwrap()
)
.build()
.unwrap()
)
.unwrap(),
serde_json::json! {{
"id": "tp_pl_action_002",
"name": "Execute Dynamic Action",
"lines": {
"action": [
{
"language": "default",
"data" : [
{
"lineFormat":"Do something with value {$tp_pl_002_text$}"
}
]
}
]
},
"type": "communicate",
"data": [
{
"type": "text",
"default": "",
"id": "tp_pl_002_text"
}
]
} }
);
}
#[test]
fn serialize_tutorial_sdk_plugin_multi_lang_action() {
assert_eq!(
serde_json::to_value(
Action::builder()
.id("tp_pl_action_002")
.name("Do something")
.translated_names(I18nNames::builder().dutch("Doe iets").build().unwrap())
.implementation(ActionImplementation::Dynamic)
.datum(
Data::builder()
.id("tp_pl_002_text")
.format(DataFormat::Text(TextData::builder().build().unwrap()))
.build()
.unwrap()
)
.lines(
Lines::builder()
.action(
LingualLine::builder()
.datum(
Line::builder()
.line_format("This actions shows multiple lines;")
.build()
.unwrap()
)
.datum(
Line::builder()
.line_format(
"Do something with value {$tp_pl_002_text$}"
)
.build()
.unwrap()
)
.build()
.unwrap()
)
.action(
LingualLine::builder()
.language("nl")
.datum(
Line::builder()
.line_format("Deze actie bevat meerdere regels;")
.build()
.unwrap()
)
.datum(
Line::builder()
.line_format("Doe iets met waarde {$tp_pl_002_text$}")
.build()
.unwrap()
)
.build()
.unwrap()
)
.build()
.unwrap()
)
.build()
.unwrap()
)
.unwrap(),
serde_json::json! {{
"id": "tp_pl_action_002",
"name": "Do something",
"name_nl": "Doe iets",
"lines": {
"action": [
{
"language": "default",
"data" : [
{
"lineFormat":"This actions shows multiple lines;",
},
{
"lineFormat":"Do something with value {$tp_pl_002_text$}"
}
]
},
{
"language": "nl",
"data" : [
{
"lineFormat":"Deze actie bevat meerdere regels;",
},
{
"lineFormat":"Doe iets met waarde {$tp_pl_002_text$}"
}
]
}
]
},
"type": "communicate",
"data": [
{
"type": "text",
"default": "",
"id": "tp_pl_002_text"
}
]
}}
);
}
}