defmodule Expression.Callbacks do @moduledoc """ The function callbacks for the standard function set available in FLOIP expressions. This should be relatively swappable with another implementation. The only requirement is the `handle/3` function. FLOIP functions are case insensitive. All functions in this callback module are implemented as lowercase names. Some functions accept a variable amount of arguments. Elixir doesn't support variable arguments in functions. If a function accepts a variable number of arguments the convention is to call the `_vargs/2` callback where the context is given as the first argument and the argument list as a second argument. Reserved names such as `and`, `if`, and `or` are suffixed with an underscore. """ @reserved_words ~w[and if or] @punctuation_pattern ~r/\s*[,:;!?.-]\s*|\s/ @doc """ Convert a string function name into an atom meant to handle that function Reserved words such as `and`, `if`, and `or` are automatically suffixed with an `_` underscore. """ def atom_function_name(function_name) when function_name in @reserved_words, do: atom_function_name("#{function_name}_") def atom_function_name(function_name) do String.to_atom(function_name) end @doc """ Handle a function call while evaluating the AST. Handlers in this module are either: 1. The function name as is 2. The function name with an underscore suffix if the function name is a reserved word 3. The function name suffixed with `_vargs` if the takes a variable set of arguments """ @spec handle(function_name :: binary, arguments :: [any], context :: map) :: {:ok, any} | {:error, :not_implemented} def handle(function_name, arguments, context) do exact_function_name = atom_function_name(function_name) vargs_function_name = atom_function_name("#{function_name}_vargs") cond do # Check if the exact function signature has been implemented function_exported?(__MODULE__, exact_function_name, length(arguments) + 1) -> {:ok, apply(__MODULE__, exact_function_name, [context] ++ arguments)} # Check if it's been implemented to accept a variable amount of arguments function_exported?(__MODULE__, vargs_function_name, 2) -> {:ok, apply(__MODULE__, vargs_function_name, [context, arguments])} # Otherwise fail true -> {:error, "#{function_name} is not implemented."} end end @doc """ Defines a new date value ``` This is a date @DATE(2012, 12, 25) ``` # Example iex> to_string(Expression.Callbacks.date(%{}, 2012, 12, 25)) "2012-12-25 00:00:00Z" """ def date(_ctx, year, month, day) do fields = [ calendar: Calendar.ISO, year: year, month: month, day: day, hour: 0, minute: 0, second: 0, time_zone: "Etc/UTC", zone_abbr: "UTC", utc_offset: 0, std_offset: 0 ] struct(DateTime, fields) end @doc """ Converts date stored in text to an actual date, using `strftime` formatting. It will fallback to "%Y-%m-%d %H:%M:%S" if no formatting is supplied ``` You joined on @DATEVALUE(contact.joined_date, "%Y-%m%-d") ``` # Example iex> date = Expression.Callbacks.date(%{}, 2020, 12, 20) iex> Expression.Callbacks.datevalue(%{}, date) "2020-12-20 00:00:00" iex> Expression.Callbacks.datevalue(%{}, date, "%Y-%m-%d") "2020-12-20" """ def datevalue(ctx, date, format \\ "%Y-%m-%d %H:%M:%S") def datevalue(_ctx, date, format) do Timex.format!(date, format, :strftime) end @doc """ Returns only the day of the month of a date (1 to 31) ``` The current day is @DAY(contact.joined_date) ``` # Example iex> now = DateTime.utc_now() iex> day = Expression.Callbacks.day(%{}, now) iex> day == now.day true """ def day(_ctx, %{day: day} = _date) do day end @doc """ Moves a date by the given number of months ``` Next month's meeting will be on @EDATE(date.today, 1) ``` # Example iex> now = DateTime.utc_now() iex> future = Timex.shift(now, months: 1) iex> date = Expression.Callbacks.edate(%{}, now, 1) iex> future == date true """ def edate(_ctx, date, months) do date |> Timex.shift(months: months) end @doc """ Returns only the hour of a datetime (0 to 23) ``` The current hour is @HOUR(NOW()) ``` # Example iex> now = DateTime.utc_now() iex> hour = Expression.Callbacks.hour(%{}, now) iex> now.hour == hour true """ def hour(_ctx, %{hour: hour} = _date) do hour end @doc """ Returns only the minute of a datetime (0 to 59) ``` The current minute is @MINUTE(NOW()) ``` # Example iex> now = DateTime.utc_now() iex> minute = Expression.Callbacks.minute(%{}, now) iex> now.minute == minute true """ def minute(_ctx, %{minute: minute} = _date) do minute end @doc """ Returns only the month of a date (1 to 12) ``` The current month is @MONTH(NOW()) ``` # Example iex> now = DateTime.utc_now() iex> month = Expression.Callbacks.month(%{}, now) iex> now.month == month true """ def month(_ctx, %{month: month} = _date) do month end @doc """ Returns the current date time as UTC ``` It is currently @NOW() ``` # Example iex> DateTime.utc_now() == Expression.Callbacks.now(%{}) """ def now(_ctx) do DateTime.utc_now() end @doc """ Returns only the second of a datetime (0 to 59) ``` The current second is @SECOND(NOW()) ``` # Example iex> now = DateTime.utc_now() iex> second = Expression.Callbacks.second(%{}, now) iex> now.second == second true """ def second(_ctx, %{second: second} = _date) do second end @doc """ Defines a time value which can be used for time arithmetic ``` 2 hours and 30 minutes from now is @(date.now + TIME(2, 30, 0)) ``` # Example iex> Expression.Callbacks.time(%{}, 12, 13, 14) %Time{hour: 12, minute: 13, second: 14} """ def time(_ctx, hours, minutes, seconds) do %Time{hour: hours, minute: minutes, second: seconds} end @doc """ Converts time stored in text to an actual time ``` Your appointment is at @(date.today + TIME("2:30")) ``` # Example iex> Expression.Callbacks.timevalue(%{}, "2:30") %Time{hour: 2, minute: 30, second: 0} iex> Expression.Callbacks.timevalue(%{}, "2:30:55") %Time{hour: 2, minute: 30, second: 55} """ def timevalue(_ctx, expression) do parts = expression |> String.split(":") |> Enum.map(&String.to_integer/1) defaults = [ hour: 0, minute: 0, second: 0 ] fields = [:hour, :minute, :second] |> Enum.zip(parts) struct(Time, Keyword.merge(defaults, fields)) end @doc """ Returns the current date ``` Today's date is @TODAY() ``` # Example iex> today = Date.utc_today() iex> today == Expression.Callbacks.today(%{}) true """ def today(_ctx) do Date.utc_today() end @doc """ Returns the day of the week of a date (1 for Sunday to 7 for Saturday) ``` Today is day no. @WEEKDAY(TODAY()) in the week ``` # Example iex> today = DateTime.utc_now() iex> expected = Timex.weekday(today) iex> weekday = Expression.Callbacks.weekday(%{}, today) iex> weekday == expected true """ def weekday(_ctx, date) do Timex.weekday(date) end @doc """ Returns only the year of a date ``` The current year is @YEAR(NOW()) ``` # Example iex> %{year: year} = now = DateTime.utc_now() iex> year == Expression.Callbacks.year(%{}, now) """ def year(_ctx, %{year: year} = _date) do year end @doc """ Returns TRUE if and only if all its arguments evaluate to TRUE ``` @AND(contact.gender = "F", contact.age >= 18) ``` # Example iex> Expression.Callbacks.handle("and", [true, true], %{}) {:ok, true} iex> Expression.Callbacks.and_vargs(%{}, [true, true]) true iex> Expression.Callbacks.and_vargs(%{}, [true, false]) false iex> Expression.Callbacks.and_vargs(%{}, [false, false]) false """ def and_vargs(_ctx, arguments) do Enum.all?(arguments, fn true -> true _other -> false end) end @doc """ Returns one value if the condition evaluates to TRUE, and another value if it evaluates to FALSE ``` Dear @IF(contact.gender = "M", "Sir", "Madam") ``` # Example iex> Expression.Callbacks.handle("if", [true, "Yes", "No"], %{}) {:ok, "Yes"} iex> Expression.Callbacks.handle("if", [false, "Yes", "No"], %{}) {:ok, "No"} """ def if_(_ctx, condition, yes, no) do if(condition, do: yes, else: no) end @doc """ Returns TRUE if any argument is TRUE ``` @OR(contact.state = "GA", contact.state = "WA", contact.state = "IN") ``` # Example iex> Expression.Callbacks.handle("or", [true, false], %{}) {:ok, true} iex> Expression.Callbacks.handle("or", [true, true], %{}) {:ok, true} iex> Expression.Callbacks.handle("or", [false, false], %{}) {:ok, false} """ def or_vargs(_ctx, arguments) do Enum.any?(arguments, fn true -> true _anything_else -> false end) end @doc """ Returns the absolute value of a number ``` The absolute value of -1 is @ABS(-1) ``` # Example iex> Expression.Callbacks.abs(%{}, -1) 1 """ def abs(_ctx, number) do abs(number) end @doc """ Returns the maximum value of all arguments ``` Please complete at most @MAX(flow.questions, 10) questions ``` # Example iex> Expression.Callbacks.handle("max", [1, 2, 3], %{}) {:ok, 3} """ def max_vargs(_ctx, arguments) do Enum.max(arguments) end @doc """ Returns the minimum value of all arguments ``` Please complete at least @MIN(flow.questions, 10) questions ``` # Example iex> Expression.Callbacks.handle("min", [1, 2, 3], %{}) {:ok, 1} """ def min_vargs(_ctx, arguments) do Enum.min(arguments) end @doc """ Returns the result of a number raised to a power - equivalent to the ^ operator ``` 2 to the power of 3 is @POWER(2, 3) ``` """ def power(_ctx, a, b) do :math.pow(a, b) end @doc """ Returns the sum of all arguments, equivalent to the + operator ``` You have @SUM(contact.reports, contact.forms) reports and forms ``` # Example iex> Expression.Callbacks.handle("sum", [1, 2, 3], %{}) {:ok, 6} """ def sum_vargs(_ctx, arguments) do Enum.sum(arguments) end @doc """ Returns the character specified by a number ``` As easy as @CHAR(65), @CHAR(66), @CHAR(67) ``` # Example iex> Expression.Callbacks.char(%{}, 65) "A" """ def char(_ctx, code) do <> end @doc """ Removes all non-printable characters from a text string ``` You entered @CLEAN(step.value) ``` # Example iex> Expression.Callbacks.clean(%{}, <<65, 0, 66, 0, 67>>) "ABC" """ def clean(_ctx, binary) do binary |> String.graphemes() |> Enum.filter(&String.printable?/1) |> Enum.join("") end @doc """ Returns a numeric code for the first character in a text string ``` The numeric code of A is @CODE("A") ``` # Example iex> Expression.Callbacks.code(%{}, "A") 65 """ def code(_ctx, <>) do code end @doc """ Joins text strings into one text string ``` Your name is @CONCATENATE(contact.first_name, " ", contact.last_name) ``` # Example iex> Expression.Callbacks.handle("concatenate", ["name", " ", "surname"], %{}) {:ok, "name surname"} """ def concatenate_vargs(_ctx, arguments) do Enum.join(arguments, "") end @doc """ Formats the given number in decimal format using a period and commas ``` You have @FIXED(contact.balance, 2) in your account ``` # Example iex> Expression.Callbacks.fixed(%{}, 4.209922, 2, false) "4.21" iex> Expression.Callbacks.fixed(%{}, 4000.424242, 4, true) "4,000.4242" iex> Expression.Callbacks.fixed(%{}, 3.7979, 2, false) "3.80" iex> Expression.Callbacks.fixed(%{}, 3.7979, 2) "3.80" """ def fixed(_ctx, number, precision, no_commas \\ false) def fixed(_ctx, number, precision, true) do Number.Delimit.number_to_delimited(number, precision: precision, delimiter: ",", separator: "." ) end def fixed(_ctx, number, precision, false) do Number.Delimit.number_to_delimited(number, precision: precision) end @doc """ Returns the first characters in a text string ``` You entered PIN @LEFT(step.value, 4) ``` # Example iex> Expression.Callbacks.left(%{}, "foobar", 4) "foob" """ def left(_ctx, binary, size) do binary_part(binary, 0, size) end @doc """ Returns the number of characters in a text string ``` You entered @LEN(step.value) characters ``` # Example iex> Expression.Callbacks.len(%{}, "foo") 3 iex> Expression.Callbacks.len(%{}, "zoë") 3 """ def len(_ctx, binary) do String.length(binary) end @doc """ Converts a text string to lowercase ```` Welcome @LOWER(contact) ``` # Example iex> Expression.Callbacks.lower(%{}, "Foo Bar") "foo bar" """ def lower(_ctx, binary) do String.downcase(binary) end @doc """ Capitalizes the first letter of every word in a text string ``` Your name is @PROPER(contact) ``` # Example iex> Expression.Callbacks.proper(%{}, "foo bar") "Foo Bar" """ def proper(_ctx, binary) do binary |> String.split(" ") |> Enum.map(&String.capitalize/1) |> Enum.join(" ") end @doc """ Repeats text a given number of times ``` Stars! @REPT("*", 10) ``` # Example iex> Expression.Callbacks.rept(%{}, "*", 10) "**********" """ def rept(_ctx, value, amount) do String.duplicate(value, amount) end @doc """ Returns the last characters in a text string ``` Your input ended with ...@RIGHT(step.value, 3) ``` # Example iex> Expression.Callbacks.right(%{}, "testing", 3) "ing" """ def right(_ctx, binary, size) do String.slice(binary, -size, size) end @doc """ Substitutes new_text for old_text in a text string. If instance_num is given, then only that instance will be substituted ``` @SUBSTITUTE(step.value, "can't", "can") ``` # Example iex> Expression.Callbacks.substitute(%{}, "I can't", "can't", "can do") "I can do" """ def substitute(%{}, subject, pattern, replacement) do String.replace(subject, pattern, replacement) end @doc """ Returns the unicode character specified by a number ``` As easy as @UNICHAR(65), @UNICHAR(66) , @UNICHAR(67) ``` # Example iex> Expression.Callbacks.unichar(%{}, 65) "A" iex> Expression.Callbacks.unichar(%{}, 233) "é" """ def unichar(_ctx, code) do <> end @doc """ Returns a numeric code for the first character in a text string ``` The numeric code of A is @UNICODE("A") ``` # Example iex> Expression.Callbacks.unicode(%{}, "A") 65 iex> Expression.Callbacks.unicode(%{}, "é") 233 """ def unicode(_ctx, <>) do code end @doc """ Converts a text string to uppercase ``` WELCOME @UPPER(contact)!! ``` # Example iex> Expression.Callbacks.upper(%{}, "foo") "FOO" """ def upper(_ctx, binary) do String.upcase(binary) end @doc """ Returns the first word in the given text - equivalent to WORD(text, 1) ``` The first word you entered was @FIRST_WORD(step.value) ``` # Example iex> Expression.Callbacks.first_word(%{}, "foo bar baz") "foo" """ def first_word(_ctx, binary) do [word | _] = String.split(binary, " ") word end @doc """ Formats a number as a percentage ``` You've completed @PERCENT(contact.reports_done / 10) reports ``` # Example iex> Expression.Callbacks.percent(%{}, 2/10) "20%" iex> Expression.Callbacks.percent(%{}, "0.2") "20%" iex> Expression.Callbacks.percent(%{}, Decimal.new("0.2")) "20%" """ @spec percent(Expression.Context.t(), float) :: binary def percent(ctx, float) when is_float(float) do percent(ctx, Decimal.from_float(float)) end @spec percent(Expression.Context.t(), binary) :: binary def percent(ctx, binary) when is_binary(binary) do percent(ctx, Decimal.new(binary)) end @spec percent(Expression.Context.t(), Decimal.t()) :: binary def percent(_ctx, decimal) do Number.Percentage.number_to_percentage(Decimal.mult(decimal, 100), precision: 0) end @doc """ Formats digits in text for reading in TTS ``` Your number is @READ_DIGITS(contact.tel_e164) ``` # Example iex> Expression.Callbacks.read_digits(%{}, "+271") "plus two seven one" """ def read_digits(_ctx, binary) do map = %{ "+" => "plus", "0" => "zero", "1" => "one", "2" => "two", "3" => "three", "4" => "four", "5" => "five", "6" => "six", "7" => "seven", "8" => "eight", "9" => "nine" } binary |> String.graphemes() |> Enum.map(fn grapheme -> Map.get(map, grapheme, nil) end) |> Enum.reject(&is_nil/1) |> Enum.join(" ") end @doc """ Removes the first word from the given text. The remaining text will be unchanged ``` You entered @REMOVE_FIRST_WORD(step.value) ``` # Example iex> Expression.Callbacks.remove_first_word(%{}, "foo bar") "bar" iex> Expression.Callbacks.remove_first_word(%{}, "foo-bar", "-") "bar" """ def remove_first_word(_ctx, binary, separator \\ " ") def remove_first_word(_ctx, binary, separator) do tl(String.split(binary, separator)) |> Enum.join(separator) end @doc """ Extracts the nth word from the given text string. If stop is a negative number, then it is treated as count backwards from the end of the text. If by_spaces is specified and is TRUE then the function splits the text into words only by spaces. Otherwise the text is split by punctuation characters as well # Example iex> Expression.Callbacks.word(%{}, "hello cow-boy", 2) "cow" iex> Expression.Callbacks.word(%{}, "hello cow-boy", 2, true) "cow-boy" iex> Expression.Callbacks.word(%{}, "hello cow-boy", -1) "boy" """ def word(ctx, binary, n, by_spaces \\ false) def word(_ctx, binary, n, by_spaces) do splitter = if(by_spaces, do: " ", else: @punctuation_pattern) parts = String.split(binary, splitter) # This slicing seems off. [part] = if n < 0 do Enum.slice(parts, n, 1) else Enum.slice(parts, n - 1, 1) end part end @doc """ Returns the number of words in the given text string. If by_spaces is specified and is TRUE then the function splits the text into words only by spaces. Otherwise the text is split by punctuation characters as well ``` You entered @WORD_COUNT(step.value) words ``` # Example iex> Expression.Callbacks.word_count(%{}, "hello cow-boy") 3 iex> Expression.Callbacks.word_count(%{}, "hello cow-boy", true) 2 """ def word_count(ctx, binary, by_spaces \\ false) def word_count(_ctx, binary, by_spaces) do splitter = if(by_spaces, do: " ", else: @punctuation_pattern) binary |> String.split(splitter) |> Enum.count() end @doc """ Extracts a substring of the words beginning at start, and up to but not-including stop. If stop is omitted then the substring will be all words from start until the end of the text. If stop is a negative number, then it is treated as count backwards from the end of the text. If by_spaces is specified and is TRUE then the function splits the text into words only by spaces. Otherwise the text is split by punctuation characters as well # Example iex> Expression.Callbacks.word_slice(%{}, "RapidPro expressions are fun", 2, 4) "expressions are" iex> Expression.Callbacks.word_slice(%{}, "RapidPro expressions are fun", 2) "expressions are fun" iex> Expression.Callbacks.word_slice(%{}, "RapidPro expressions are fun", 1, -2) "RapidPro expressions" iex> Expression.Callbacks.word_slice(%{}, "RapidPro expressions are fun", -1) "fun" """ def word_slice(_ctx, binary, start) when start > 0 do parts = binary |> String.split(" ") parts |> Enum.slice(start - 1, length(parts)) |> Enum.join(" ") end def word_slice(_ctx, binary, start) when start < 0 do parts = binary |> String.split(" ") parts |> Enum.slice(start..length(parts)) |> Enum.join(" ") end def word_slice(_ctx, binary, start, stop, by_spaces \\ false) def word_slice(_ctx, binary, start, stop, by_spaces) when stop > 0 do splitter = if(by_spaces, do: " ", else: @punctuation_pattern) binary |> String.split(splitter) |> Enum.slice((start - 1)..(stop - 2)) |> Enum.join(" ") end def word_slice(_ctx, binary, start, stop, by_spaces) when stop < 0 do splitter = if(by_spaces, do: " ", else: @punctuation_pattern) binary |> String.split(splitter) |> Enum.slice((start - 1)..(stop - 1)) |> Enum.join(" ") end @doc """ Returns TRUE if the argument is a number. ``` @ISNUMBER(contact.age) will return TRUE if the contact's age is a number. ``` # Example iex> Expression.Callbacks.isnumber(%{}, 1) true iex> Expression.Callbacks.isnumber(%{}, 1.0) true iex> Expression.Callbacks.isnumber(%{}, Decimal.new("1.0")) true iex> Expression.Callbacks.isnumber(%{}, "1.0") true iex> Expression.Callbacks.isnumber(%{}, "a") false """ def isnumber(_ctx, var) when is_float(var) or is_integer(var), do: true def isnumber(_ctx, %{__struct__: Decimal}), do: true def isnumber(_ctx, var) when is_binary(var) do Decimal.new(var) true rescue Decimal.Error -> false end def isnumber(_ctx, _var), do: false @doc """ Returns TRUE if the argument is a boolean. ``` @ISBOOL(block.value) will return TRUE if the block returned a boolean value. ``` # Example iex> Expression.Callbacks.isbool(%{}, true) true iex> Expression.Callbacks.isbool(%{}, false) true iex> Expression.Callbacks.isbool(%{}, 1) false iex> Expression.Callbacks.isbool(%{}, 0) false iex> Expression.Callbacks.isbool(%{}, "true") false iex> Expression.Callbacks.isbool(%{}, "false") false """ def isbool(_ctx, var) when var in [true, false], do: true def isbool(_ctx, _var), do: false @doc """ Returns TRUE if the argument is a string. ``` @ISSTRING(contact.name) will return TRUE if the contact's name is a string. ``` # Example iex> Expression.Callbacks.isstring(%{}, "hello") true iex> Expression.Callbacks.isstring(%{}, false) false iex> Expression.Callbacks.isstring(%{}, 1) false iex> Expression.Callbacks.isstring(%{}, Decimal.new("1.0")) false """ def isstring(_ctx, binary), do: is_binary(binary) defp search_words(haystack, words) do patterns = words |> String.split(" ") |> Enum.map(&Regex.escape/1) |> Enum.map(&Regex.compile!(&1, "i")) results = patterns |> Enum.map(&Regex.run(&1, haystack)) |> Enum.map(fn [match] -> match nil -> nil end) |> Enum.reject(&is_nil/1) {patterns, results} end @doc """ Tests whether all the words are contained in text The words can be in any order and may appear more than once. ``` @(has_all_words("the quick brown FOX", "the fox")) → true @(has_all_words("the quick brown fox", "red fox")) → false ``` NOTE: the flowspec supports `.match` which isn't support here yet. ``` @(has_all_words("the quick brown FOX", "the fox").match) → the FOX ``` # Example iex> Expression.Callbacks.has_all_words(%{}, "the quick brown FOX", "the fox") {:ok, true} iex> Expression.Callbacks.has_all_words(%{}, "the quick brown FOX", "red fox") {:ok, false} """ def has_all_words(_ctx, haystack, words) do {patterns, results} = search_words(haystack, words) # future match result: Enum.join(results, " ") {:ok, Enum.count(patterns) == Enum.count(results)} end @doc """ Tests whether any of the words are contained in the text Only one of the words needs to match and it may appear more than once. ``` @(has_any_word("The Quick Brown Fox", "fox quick")) → true ``` Unsupported: ``` @(has_any_word("The Quick Brown Fox", "fox quick").match) → Quick Fox @(has_any_word("The Quick Brown Fox", "red fox").match) → Fox ``` # Example iex> Expression.Callbacks.has_any_word(%{}, "The Quick Brown Fox", "fox quick") {:ok, true} iex> Expression.Callbacks.has_any_word(%{}, "The Quick Brown Fox", "yellow") {:ok, false} """ def has_any_word(_ctx, haystack, words) do {_patterns, results} = search_words(haystack, words) # future match result Enum.join(results, " ") {:ok, Enum.any?(results)} end @doc """ Tests whether text starts with beginning Both text values are trimmed of surrounding whitespace, but otherwise matching is strict without any tokenization. Supported: ``` @(has_beginning("The Quick Brown", "the quick")) → true @(has_beginning("The Quick Brown", "the quick")) → false @(has_beginning("The Quick Brown", "quick brown")) → false ``` Unsupported ``` @(has_beginning("The Quick Brown", "the quick").match) → The Quick ``` # Example iex> Expression.Callbacks.has_beginning(%{}, "The Quick Brown", "the quick") {:ok, true} iex> Expression.Callbacks.has_beginning(%{}, "The Quick Brown", "the quick") {:ok, false} iex> Expression.Callbacks.has_beginning(%{}, "The Quick Brown", "quick brown") {:ok, false} """ def has_beginning(_ctx, text, beginning) do case Regex.run(~r/^#{Regex.escape(beginning)}/i, text) do # future match result: first [_first | _remainder] -> {:ok, true} nil -> {:ok, false} end end defp extract_dateish(expression) do expression = Regex.replace(~r/[a-z]/u, expression, "") case DateTimeParser.parse_date(expression) do {:ok, date} -> date {:error, _} -> nil end end @doc """ Tests whether `expression` contains a date formatted according to our environment This is very naively implemented with a regular expression. Supported: ``` @(has_date("the date is 15/01/2017")) → true @(has_date("there is no date here, just a year 2017")) → false ``` Unsupported: ``` @(has_date("the date is 15/01/2017").match) → 2017-01-15T13:24:30.123456-05:00 ``` # Example iex> Expression.Callbacks.has_date(%{}, "the date is 15/01/2017") {:ok, true} iex> Expression.Callbacks.has_date(%{}, "there is no date here, just a year 2017") {:ok, false} """ def has_date(_, expression) do date = extract_dateish(expression) # future match result: date {:ok, !!date} end @doc """ Tests whether `expression` is a date equal to `date_string` Supported: ``` @(has_date_eq("the date is 15/01/2017", "2017-01-15")) → true @(has_date_eq("there is no date here, just a year 2017", "2017-06-01")) → false @(has_date_eq("there is no date here, just a year 2017", "not date")) → ERROR ``` Not supported: ``` @(has_date_eq("the date is 15/01/2017", "2017-01-15").match) → 2017-01-15T13:24:30.123456-05:00 @(has_date_eq("the date is 15/01/2017 15:00", "2017-01-15").match) → 2017-01-15T15:00:00.000000-05:00 ``` # Examples iex> Expression.Callbacks.has_date_eq(%{}, "the date is 15/01/2017", "2017-01-15") {:ok, true} iex> Expression.Callbacks.has_date_eq(%{}, "there is no date here, just a year 2017", "2017-01-15") {:ok, false} """ def has_date_eq(_ctx, expression, date_string) do found_date = extract_dateish(expression) test_date = extract_dateish(date_string) # Future match result: found_date {:ok, found_date == test_date} end @doc """ Tests whether `expression` is a date after the date `date_string` ``` @(has_date_gt("the date is 15/01/2017", "2017-01-01")) → true @(has_date_gt("the date is 15/01/2017", "2017-03-15")) → false @(has_date_gt("there is no date here, just a year 2017", "2017-06-01")) → false @(has_date_gt("there is no date here, just a year 2017", "not date")) → ERROR ``` Not supported: ``` @(has_date_gt("the date is 15/01/2017", "2017-01-01").match) → 2017-01-15T13:24:30.123456-05:00 ``` # Example iex> Expression.Callbacks.has_date_gt(%{}, "the date is 15/01/2017", "2017-01-01") {:ok, true} iex> Expression.Callbacks.has_date_gt(%{}, "the date is 15/01/2017", "2017-03-15") {:ok, false} """ def has_date_gt(_ctx, expression, date_string) do found_date = extract_dateish(expression) test_date = extract_dateish(date_string) # future match result: found_date {:ok, Date.compare(found_date, test_date) == :gt} end @doc """ Tests whether `expression` contains a date before the date `date_string` ``` @(has_date_lt("the date is 15/01/2017", "2017-06-01")) → true @(has_date_lt("there is no date here, just a year 2017", "2017-06-01")) → false @(has_date_lt("there is no date here, just a year 2017", "not date")) → ERROR ``` Not supported: ``` @(has_date_lt("the date is 15/01/2017", "2017-06-01").match) → 2017-01-15T13:24:30.123456-05:00 ``` # Example iex> Expression.Callbacks.has_date_lt(%{}, "the date is 15/01/2017", "2017-06-01") {:ok, true} iex> Expression.Callbacks.has_date_lt(%{}, "the date is 15/01/2021", "2017-03-15") {:ok, false} """ def has_date_lt(_ctx, expression, date_string) do found_date = extract_dateish(expression) test_date = extract_dateish(date_string) # future match result: found_date {:ok, Date.compare(found_date, test_date) == :lt} end @doc """ Tests whether an email is contained in text ``` @(has_email("my email is foo1@bar.com, please respond")) → true @(has_email("i'm not sharing my email")) → false ``` Not supported: ``` @(has_email("my email is foo1@bar.com, please respond").match) → foo1@bar.com @(has_email("my email is ").match) → foo@bar2.com ``` # Example: iex> Expression.Callbacks.has_email(%{}, "my email is foo1@bar.com, please respond") {:ok, true} iex> Expression.Callbacks.has_email(%{}, "i'm not sharing my email") {:ok, false} """ def has_email(_ctx, expression) do case Regex.run(~r/([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)/, expression) do # future match result: match [_match | _] -> {:ok, true} nil -> {:ok, false} end end @doc """ Returns whether the contact is part of group with the passed in UUID ``` @(has_group(array(), "97fe7029-3a15-4005-b0c7-277b884fc1d5")) → false ``` Not supported: ``` @(has_group(contact.groups, "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d").match) → {name: Testers, uuid: b7cf0d83-f1c9-411c-96fd-c511a4cfa86d} ``` # Example: iex> contact = %{ ...> "groups" => [%{ ...> "uuid" => "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d" ...> }] ...> } iex> Expression.Callbacks.has_group(%{}, contact["groups"], "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d") {:ok, true} iex> Expression.Callbacks.has_group(%{}, contact["groups"], "00000000-0000-0000-0000-000000000000") {:ok, false} """ def has_group(_ctx, groups, uuid) do group = Enum.find(groups, nil, &(&1["uuid"] == uuid)) # future match result: group {:ok, !!group} end defp extract_numberish(expression) do with [match] <- Regex.run(~r/([0-9]+\.?[0-9]+)/u, replace_arabic_numerals(expression), capture: :first), {decimal, ""} <- Decimal.parse(match) do decimal else # Regex can return nil nil -> nil # Decimal parsing can return :error :error -> nil end end defp replace_arabic_numerals(expression) do replace_numerals(expression, %{ "٠" => "0", "١" => "1", "٢" => "2", "٣" => "3", "٤" => "4", "٥" => "5", "٦" => "6", "٧" => "7", "٨" => "8", "٩" => "9" }) end defp replace_numerals(expression, mapping) do mapping |> Enum.reduce(expression, fn {rune, replacement}, expression -> String.replace(expression, rune, replacement) end) end defp parse_decimal(float) when is_float(float), do: Decimal.from_float(float) defp parse_decimal(number) when is_number(number), do: Decimal.new(number) defp parse_decimal(binary) when is_binary(binary) do case Decimal.parse(binary) do {decimal, ""} -> decimal :error -> :error end end @doc """ Tests whether `expression` contains a number ``` @(has_number("the number is 42")) → true @(has_number("the number is forty two")) → false ``` Not supported: ``` @(has_number("the number is 42").match) → 42 @(has_number("العدد ٤٢").match) → 42 ``` # Example iex> {:ok, true} = Expression.Callbacks.has_number(%{}, "the number is 42 and 5") iex> {:ok, true} = Expression.Callbacks.has_number(%{}, "العدد ٤٢") iex> {:ok, true} = Expression.Callbacks.has_number(%{}, "٠.٥") iex> {:ok, true} = Expression.Callbacks.has_number(%{}, "0.6") """ def has_number(_ctx, expression) do number = extract_numberish(expression) # future match result: number {:ok, !!number} end @doc """ Tests whether `expression` contains a number equal to the value ``` @(has_number_eq("the number is 42", 42)) → true @(has_number_eq("the number is 42", 40)) → false @(has_number_eq("the number is not there", 40)) → false @(has_number_eq("the number is not there", "foo")) → ERROR ``` Not supported: ``` @(has_number_eq("the number is 42", 42).match) → 42 ``` # Example iex> {:ok, true} = Expression.Callbacks.has_number_eq(%{}, "the number is 42", 42) iex> {:ok, true} = Expression.Callbacks.has_number_eq(%{}, "the number is 42", 42.0) iex> {:ok, true} = Expression.Callbacks.has_number_eq(%{}, "the number is 42", "42") iex> {:ok, true} = Expression.Callbacks.has_number_eq(%{}, "the number is 42.0", "42") iex> {:ok, false} = Expression.Callbacks.has_number_eq(%{}, "the number is 40", "42") iex> {:ok, false} = Expression.Callbacks.has_number_eq(%{}, "the number is 40", "foo") iex> {:ok, false} = Expression.Callbacks.has_number_eq(%{}, "four hundred", "foo") """ def has_number_eq(_ctx, expression, decimal) do with %Decimal{} = number <- extract_numberish(expression), %Decimal{} = decimal <- parse_decimal(decimal) do # Future match result: number {:ok, Decimal.eq?(number, decimal)} else nil -> {:ok, false} :error -> {:ok, false} end end @doc """ Tests whether `expression` contains a number greater than min ``` @(has_number_gt("the number is 42", 40)) → true @(has_number_gt("the number is 42", 42)) → false @(has_number_gt("the number is not there", 40)) → false @(has_number_gt("the number is not there", "foo")) → ERROR ``` Not supported: ``` @(has_number_gt("the number is 42", 40).match) → 42 ``` # Example iex> {:ok, true} = Expression.Callbacks.has_number_gt(%{}, "the number is 42", 40) iex> {:ok, true} = Expression.Callbacks.has_number_gt(%{}, "the number is 42", 40.0) iex> {:ok, true} = Expression.Callbacks.has_number_gt(%{}, "the number is 42", "40") iex> {:ok, true} = Expression.Callbacks.has_number_gt(%{}, "the number is 42.0", "40") iex> {:ok, false} = Expression.Callbacks.has_number_gt(%{}, "the number is 40", "40") iex> {:ok, false} = Expression.Callbacks.has_number_gt(%{}, "the number is 40", "foo") iex> {:ok, false} = Expression.Callbacks.has_number_gt(%{}, "four hundred", "foo") """ def has_number_gt(_ctx, expression, decimal) do with %Decimal{} = number <- extract_numberish(expression), %Decimal{} = decimal <- parse_decimal(decimal) do # Future match result: number {:ok, Decimal.gt?(number, decimal)} else nil -> {:ok, false} :error -> {:ok, false} end end @doc """ Tests whether `expression` contains a number greater than or equal to min ``` @(has_number_gte("the number is 42", 42)) → true @(has_number_gte("the number is 42", 45)) → false @(has_number_gte("the number is not there", 40)) → false @(has_number_gte("the number is not there", "foo")) → ERROR ``` Not supported: ``` @(has_number_gte("the number is 42", 42).match) → 42 ``` # Example iex> {:ok, true} = Expression.Callbacks.has_number_gte(%{}, "the number is 42", 42) iex> {:ok, true} = Expression.Callbacks.has_number_gte(%{}, "the number is 42", 42.0) iex> {:ok, true} = Expression.Callbacks.has_number_gte(%{}, "the number is 42", "42") iex> {:ok, false} = Expression.Callbacks.has_number_gte(%{}, "the number is 42.0", "45") iex> {:ok, false} = Expression.Callbacks.has_number_gte(%{}, "the number is 40", "45") iex> {:ok, false} = Expression.Callbacks.has_number_gte(%{}, "the number is 40", "foo") iex> {:ok, false} = Expression.Callbacks.has_number_gte(%{}, "four hundred", "foo") """ def has_number_gte(_ctx, expression, decimal) do with %Decimal{} = number <- extract_numberish(expression), %Decimal{} = decimal <- parse_decimal(decimal) do # Future match result: number {:ok, Decimal.gt?(number, decimal) || Decimal.eq?(number, decimal)} else nil -> {:ok, false} :error -> {:ok, false} end end @doc """ Tests whether `expression` contains a number less than max ``` @(has_number_lt("the number is 42", 44)) → true @(has_number_lt("the number is 42", 40)) → false @(has_number_lt("the number is not there", 40)) → false @(has_number_lt("the number is not there", "foo")) → ERROR ``` Not supported: ``` @(has_number_lt("the number is 42", 44).match) → 42 ``` # Example iex> {:ok, true} = Expression.Callbacks.has_number_lt(%{}, "the number is 42", 44) iex> {:ok, true} = Expression.Callbacks.has_number_lt(%{}, "the number is 42", 44.0) iex> {:ok, false} = Expression.Callbacks.has_number_lt(%{}, "the number is 42", "40") iex> {:ok, false} = Expression.Callbacks.has_number_lt(%{}, "the number is 42.0", "40") iex> {:ok, false} = Expression.Callbacks.has_number_lt(%{}, "the number is 40", "40") iex> {:ok, false} = Expression.Callbacks.has_number_lt(%{}, "the number is 40", "foo") iex> {:ok, false} = Expression.Callbacks.has_number_lt(%{}, "four hundred", "foo") """ def has_number_lt(_ctx, expression, decimal) do with %Decimal{} = number <- extract_numberish(expression), %Decimal{} = decimal <- parse_decimal(decimal) do # Future match result: number {:ok, Decimal.lt?(number, decimal)} else nil -> {:ok, false} :error -> {:ok, false} end end @doc """ Tests whether `expression` contains a number less than or equal to max ``` @(has_number_lte("the number is 42", 42)) → true @(has_number_lte("the number is 42", 40)) → false @(has_number_lte("the number is not there", 40)) → false @(has_number_lte("the number is not there", "foo")) → ERROR ``` Not supported: ``` @(has_number_lte("the number is 42", 42).match) → 42 ``` # Example iex> {:ok, true} = Expression.Callbacks.has_number_lte(%{}, "the number is 42", 42) iex> {:ok, true} = Expression.Callbacks.has_number_lte(%{}, "the number is 42", 42.0) iex> {:ok, true} = Expression.Callbacks.has_number_lte(%{}, "the number is 42", "42") iex> {:ok, false} = Expression.Callbacks.has_number_lte(%{}, "the number is 42.0", "40") iex> {:ok, false} = Expression.Callbacks.has_number_lte(%{}, "the number is 40", "foo") iex> {:ok, false} = Expression.Callbacks.has_number_lte(%{}, "four hundred", "foo") """ def has_number_lte(_ctx, expression, decimal) do with %Decimal{} = number <- extract_numberish(expression), %Decimal{} = decimal <- parse_decimal(decimal) do # Future match result: number {:ok, Decimal.lt?(number, decimal) || Decimal.eq?(number, decimal)} else nil -> {:ok, false} :error -> {:ok, false} end end @doc """ Tests whether the text contains only phrase The phrase must be the only text in the text to match ``` @(has_only_phrase("Quick Brown", "quick brown")) → true @(has_only_phrase("The Quick Brown Fox", "quick brown")) → false @(has_only_phrase("the Quick Brown fox", "")) → false @(has_only_phrase("", "").match) → @(has_only_phrase("The Quick Brown Fox", "red fox")) → false ``` Not supported: ``` @(has_only_phrase("Quick Brown", "quick brown").match) → Quick Brown ``` # Example iex> Expression.Callbacks.has_only_phrase(%{}, "Quick Brown", "quick brown") {:ok, true} iex> Expression.Callbacks.has_only_phrase(%{}, "", "") {:ok, true} iex> Expression.Callbacks.has_only_phrase(%{}, "The Quick Brown Fox", "quick brown") {:ok, false} """ def has_only_phrase(_ctx, expression, phrase) do case Enum.map([expression, phrase], &String.downcase/1) do # Future match result: expression [same, same] -> {:ok, true} _anything_else -> {:ok, false} end end @doc """ Returns whether two text values are equal (case sensitive). In the case that they are, it will return the text as the match. ``` @(has_only_text("foo", "foo")) → true @(has_only_text("foo", "FOO")) → false @(has_only_text("foo", "bar")) → false @(has_only_text("foo", " foo ")) → false @(has_only_text(results.webhook.category, "Failure")) → false ``` Not supported: ``` @(has_only_text("foo", "foo").match) → foo @(has_only_text(run.status, "completed").match) → completed @(has_only_text(results.webhook.category, "Success").match) → Success ``` # Example iex> Expression.Callbacks.has_only_text(%{}, "foo", "foo") {:ok, true} iex> Expression.Callbacks.has_only_text(%{}, "", "") {:ok, true} iex> Expression.Callbacks.has_only_text(%{}, "foo", "FOO") {:ok, false} """ def has_only_text(_ctx, expression, expression) when is_binary(expression), # future match result: expression do: {:ok, true} def has_only_text(_ctx, _expression, _something_else), # Future match result: expression do: {:ok, false} @doc """ Tests whether `expression` matches the regex pattern Both text values are trimmed of surrounding whitespace and matching is case-insensitive. ``` @(has_pattern("Buy cheese please", "buy (\w+)")) → true @(has_pattern("Sell cheese please", "buy (\w+)")) → false ``` Not supported: ``` @(has_pattern("Buy cheese please", "buy (\w+)").match) → Buy cheese @(has_pattern("Buy cheese please", "buy (\w+)").extra) → {0: Buy cheese, 1: cheese} ``` # Examples iex> Expression.Callbacks.has_pattern(%{}, "Buy cheese please", "buy (\\\\w+)") {:ok, true} iex> Expression.Callbacks.has_pattern(%{}, "Sell cheese please", "buy (\\\\w+)") {:ok, false} """ def has_pattern(_ctx, expression, pattern) do with {:ok, regex} <- Regex.compile(String.trim(pattern), "i"), [[_first | _remainder]] <- Regex.scan(regex, String.trim(expression), capture: :all) do # Future match result: first {:ok, true} else _ -> {:ok, false} end end @doc """ Tests whether `expresssion` contains a phone number. The optional country_code argument specifies the country to use for parsing. ``` @(has_phone("my number is +12067799294 thanks")) → true @(has_phone("my number is none of your business", "US")) → false ``` Not supported: ``` @(has_phone("my number is +12067799294").match) → +12067799294 @(has_phone("my number is 2067799294", "US").match) → +12067799294 @(has_phone("my number is 206 779 9294", "US").match) → +12067799294 ``` # Example iex> Expression.Callbacks.has_phone(%{}, "my number is +12067799294 thanks") {:ok, true} iex> Expression.Callbacks.has_phone(%{}, "my number is 2067799294 thanks", "US") {:ok, true} iex> Expression.Callbacks.has_phone(%{}, "my number is 206 779 9294 thanks", "US") {:ok, true} iex> Expression.Callbacks.has_phone(%{}, "my number is none of your business", "US") {:ok, false} """ def has_phone(%{}, expression, country_code \\ "") do letters_removed = Regex.replace(~r/[a-z]/i, expression, "") case ExPhoneNumber.parse(letters_removed, country_code) do # Future match result: ExPhoneNumber.format(pn, :es164) {:ok, _pn} -> {:ok, true} _ -> {:ok, false} end end @doc """ Tests whether phrase is contained in `expression` The words in the test phrase must appear in the same order with no other words in between. ``` @(has_phrase("the quick brown fox", "brown fox")) → true @(has_phrase("the Quick Brown fox", "quick fox")) → false @(has_phrase("the Quick Brown fox", "").match) → ``` Not supported: ``` @(has_phrase("the quick brown fox", "brown fox").match) → brown fox ``` # Examples iex> Expression.Callbacks.has_phrase(%{}, "the quick brown fox", "brown fox") {:ok, true} iex> Expression.Callbacks.has_phrase(%{}, "the quick brown fox", "quick fox") {:ok, false} iex> Expression.Callbacks.has_phrase(%{}, "the quick brown fox", "") {:ok, true} """ def has_phrase(_ctx, expression, phrase) do lower_expression = String.downcase(expression) lower_phrase = String.downcase(phrase) found? = String.contains?(lower_expression, lower_phrase) # Future match result: phrase {:ok, found?} end @doc """ Tests whether there the `expression` has any characters in it ``` @(has_text("quick brown")) → true @(has_text("")) → false @(has_text(" \n")) → false @(has_text(contact.fields.not_set)) → false ``` Not supported: ``` @(has_text("quick brown").match) → quick brown @(has_text(123).match) → 123 ``` # Examples iex> Expression.Callbacks.has_text(%{}, "quick brown") {:ok, true} iex> Expression.Callbacks.has_text(%{}, "") {:ok, false} iex> Expression.Callbacks.has_text(%{}, " \\n") {:ok, false} iex> Expression.Callbacks.has_text(%{}, 123) {:ok, true} iex> Expression.Callbacks.has_text(%{}, nil) {:ok, false} """ def has_text(ctx, expression) when not is_binary(expression), do: has_text(ctx, to_string(expression)) def has_text(_ctx, expression) when is_binary(expression) do case String.trim(expression) do "" -> {:ok, false} # Future match result: any_other_binary _any_other_binary -> {:ok, true} end end @doc """ Tests whether `expression` contains a time. ``` @(has_time("the time is 10:30")) → true @(has_time("the time is 10:30:45").match) → 10:30:45.000000 @(has_time("there is no time here, just the number 25")) → false ``` Not supported: ``` @(has_time("the time is 10:30").match) → 10:30:00.000000 @(has_time("the time is 10 PM").match) → 22:00:00.000000 ``` # Examples iex> Expression.Callbacks.has_time(%{}, "the time is 10:30") {:ok, true} iex> Expression.Callbacks.has_time(%{}, "the time is 10:00 pm") {:ok, true} iex> Expression.Callbacks.has_time(%{}, "the time is 10:30:45") {:ok, true} iex> Expression.Callbacks.has_time(%{}, "there is no time here, just the number 25") {:ok, false} """ def has_time(_ctx, expression) do case DateTimeParser.parse_time(expression) do # Future match result: time {:ok, _time} -> {:ok, true} _ -> {:ok, false} end end end