defmodule Expression.V2.Callbacks.Standard do @moduledoc """ Callback functions to be used in Expressions. This is the same idea as `Expression.Callbacks.Standard` but it's in a rough shape, mostly to just prove that this all works. """ use Expression.V2.Callbacks use Expression.V2.Autodoc alias Expression.DateHelpers @punctuation_pattern ~r/\s*[,:;!?.-]\s*|\s/ @doc """ Defines a new date value """ @expression_doc doc: "Construct a date from year, month, and day integers", expression: "date(year, month, day)", context: %{ "year" => 2022, "month" => 1, "day" => 31 }, result: ~D[2022-01-31] def date(_ctx, year, month, day) do fields = [ calendar: Calendar.ISO, year: year, month: month, day: day, time_zone: "Etc/UTC", zone_abbr: "UTC" ] struct(Date, fields) end @doc """ Calculates a new datetime based on the offset and unit provided. The unit can be any of the following values: * "Y" for years * "M" for months * "W" for weeks * "D" for days * "h" for hours * "m" for minutes * "s" for seconds Specifying a negative offset results in date calculations back in time. """ @expression_doc doc: "Calculates a new datetime based on the offset and unit provided.", expression: "datetime_add(datetime, offset, unit)", context: %{ "datetime" => ~U[2022-07-31 00:00:00Z], "offset" => 1, "unit" => "M" }, result: ~U[2022-08-31 00:00:00Z] @expression_doc doc: "Leap year handling in a leap year.", expression: "datetime_add(date(2020, 02, 28), 1, \"D\")", result: ~U[2020-02-29 00:00:00.000000Z] @expression_doc doc: "Leap year handling outside of a leap year.", expression: "datetime_add(date(2021, 02, 28), 1, \"D\")", result: ~U[2021-03-01 00:00:00.000000Z] @expression_doc doc: "Negative offsets", expression: "datetime_add(date(2020, 02, 29), -1, \"D\")", result: ~U[2020-02-28 00:00:00.000000Z] def datetime_add(_ctx, datetime, offset, unit) do datetime = DateHelpers.extract_datetimeish(datetime) case unit do "Y" -> Timex.shift(datetime, years: offset) "M" -> Timex.shift(datetime, months: offset) "W" -> Timex.shift(datetime, weeks: offset) "D" -> Timex.shift(datetime, days: offset) "h" -> Timex.shift(datetime, hours: offset) "m" -> Timex.shift(datetime, minutes: offset) "s" -> Timex.shift(datetime, seconds: offset) end end @doc """ Converts date stored in text to an actual date object and formats it using `strftime` formatting. It will fallback to "%Y-%m-%d %H:%M:%S" if no formatting is supplied """ @expression_doc doc: "Convert a date from a piece of text to a formatted date string", expression: "datevalue(\"2022-01-01\")", result: %{"__value__" => "2022-01-01 00:00:00", "date" => ~D[2022-01-01]} @expression_doc doc: "Convert a date from a piece of text and read the date field", expression: "datevalue(\"2022-01-02\").date", result: ~D[2022-01-02] @expression_doc doc: "Convert a date value and read the date field", expression: "datevalue(date(2022, 1, 3)).date", result: ~D[2022-01-03] def datevalue(_ctx, date, format \\ "%Y-%m-%d %H:%M:%S") do case DateHelpers.extract_dateish(date) do nil -> %{"__value__" => "", "date" => nil} date -> %{"__value__" => Timex.format!(date, format, :strftime), "date" => date} end end @doc """ Returns only the day of the month of a date (1 to 31) """ @expression_doc doc: "Getting today's day of the month", expression: "day(date(2022, 9, 10))", result: 10 @expression_doc doc: "Getting today's day of the month", expression: "day(now())", fake_result: DateTime.utc_now().day def day(_ctx, %{day: day} = _date) do day end @doc """ Moves a date by the given number of months """ @expression_doc doc: "Move the date in a date object by 1 month", expression: "edate(right_now, 1)", context: %{ "right_now" => DateTime.new!(Date.new!(2022, 1, 1), Time.new!(0, 0, 0)) }, result: Timex.shift(DateTime.new!(Date.new!(2022, 1, 1), Time.new!(0, 0, 0)), months: 1 ) @expression_doc doc: "Move the date store in a piece of text by 1 month", expression: "edate(\"2022-10-10\", 1)", result: ~D[2022-11-10] def edate(_ctx, date, months) do DateHelpers.extract_dateish(date) |> Timex.shift(months: months) end @doc """ Returns only the hour of a datetime (0 to 23) """ @expression_doc doc: "Get the current hour", expression: "hour(now())", fake_result: DateTime.utc_now().hour def hour(_ctx, %{hour: hour} = _date) do hour end @doc """ Returns only the minute of a datetime (0 to 59) """ @expression_doc doc: "Get the current minute", expression: "minute(now())", fake_result: DateTime.utc_now().minute def minute(_ctx, date) do %{minute: minute} = DateHelpers.extract_datetimeish(date) minute end @doc """ Returns only the month of a date (1 to 12) """ @expression_doc doc: "Get the current month", expression: "month(now())", fake_result: DateTime.utc_now().month def month(_ctx, %{month: month} = _date) do month end @doc """ Returns the current date time as UTC ``` It is currently @NOW() ``` """ @expression_doc doc: "return the current timestamp as a DateTime value", expression: "now()", fake_result: DateTime.utc_now() @expression_doc doc: "return the current datetime and format it using `datevalue`", expression: "datevalue(now(), \"%Y-%m-%d\")", fake_result: %{ "__value__" => DateTime.utc_now() |> Timex.format!("%Y-%m-%d", :strftime), "date" => DateTime.utc_now() } def now(_ctx) do DateTime.utc_now() end @doc """ Returns only the second of a datetime (0 to 59) """ @expression_doc expression: "second(now)", context: %{"now" => DateTime.utc_now()}, fake_result: DateTime.utc_now().second def second(_ctx, %{second: second} = _date) do second end @doc """ Defines a time value which can be used for time arithmetic """ @expression_doc expression: "time(12, 13, 14)", result: %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 """ @expression_doc expression: "timevalue(\"2:30\")", result: %Time{hour: 2, minute: 30, second: 0} @expression_doc expression: "timevalue(\"2:30:55\")", result: %Time{hour: 2, minute: 30, second: 55} def timevalue(_ctx, expression) when is_binary(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 """ @expression_doc expression: "today()", fake_result: Date.utc_today() 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) """ @expression_doc expression: "weekday(today)", context: %{"today" => ~D[2022-11-06]}, result: 1 @expression_doc expression: "weekday(today)", context: %{"today" => ~D[2022-11-01]}, result: 3 def weekday(_ctx, date) do iso_week_day = Timex.weekday(date) if iso_week_day == 7 do 1 else iso_week_day + 1 end end @doc """ Returns only the year of a date """ @expression_doc expression: "year(now)", context: %{"now" => DateTime.utc_now()}, fake_result: DateTime.utc_now().year def year(_ctx, date) do %{year: year} = DateHelpers.extract_dateish(date) year end @doc """ Returns `true` if and only if all its arguments evaluate to `true` """ @expression_doc expression: "and(contact.gender = \"F\", contact.age >= 18)", code_expression: "contact.gender = \"F\" and contact.age >= 18", context: %{ "contact" => %{ "gender" => "F", "age" => 32 } }, result: true @expression_doc expression: "and(contact.gender = \"F\", contact.age >= 18)", code_expression: "contact.gender = \"F\" and contact.age >= 18", context: %{ "contact" => %{ "gender" => "?", "age" => 32 } }, result: false def and_vargs(_ctx, arguments) do Enum.all?(arguments, & &1) end @doc """ Returns `false` if the argument supplied evaluates to truth-y """ @expression_doc expression: "not(false)", result: true def not_(_ctx, argument) do !argument end @doc """ Returns `true` if any argument is `true`. Returns the first truthy value found or otherwise false. Accepts any amount of arguments for testing truthiness. """ @expression_doc doc: "Return true if any of the values are true", expression: "or(true, false)", code_expression: "true or false", result: true @expression_doc doc: "Return the first value that is truthy", expression: "or(false, \"foo\")", code_expression: "false or \"foo\"", result: "foo" @expression_doc expression: "or(true, true)", code_expression: "true or true", result: true @expression_doc expression: "or(false, false)", code_expression: "false or false", result: false def or_vargs(_ctx, arguments) do Enum.reduce(arguments, fn a, b -> a || b end) end @doc """ Returns the absolute value of a number """ @expression_doc expression: "abs(-1)", result: 1 def abs(_ctx, number) do Kernel.abs(number) end @doc """ Returns the maximum value of all arguments """ @expression_doc expression: "max(1, 2, 3)", result: 3 def max_vargs(_ctx, arguments) do Enum.max(arguments) end @doc """ Returns the minimum value of all arguments """ @expression_doc expression: "min(1, 2, 3)", result: 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 """ @expression_doc expression: "power(2, 3)", fake_result: 8.0 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 ``` """ @expression_doc expression: "sum(1, 2, 3)", result: 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)" "As easy as A, B, C" ``` """ @expression_doc expression: "char(65)", result: "A" def char(_ctx, code) do <> end @doc """ Removes all non-printable characters from a text string """ @expression_doc expression: "clean(value)", context: %{"value" => <<65, 0, 66, 0, 67>>}, result: "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\\")" "The numeric code of A is 65" ``` """ @expression_doc expression: "code(\"A\")", result: 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)" "Your name is name surname" ``` """ @expression_doc expression: "concatenate(contact.first_name, \" \", contact.last_name)", context: %{ "contact" => %{ "first_name" => "name", "last_name" => "surname" } }, result: "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 "You have 4.21 in your account" ``` """ @expression_doc expression: "fixed(4.209922, 2, false)", result: "4.21" @expression_doc expression: "fixed(4000.424242, 4, true)", result: "4,000.4242" @expression_doc expression: "fixed(3.7979, 2, false)", result: "3.80" @expression_doc expression: "fixed(3.7979, 2)", result: "3.80" def fixed(_ctx, number, precision, no_commas \\ false) def fixed(_ctx, number, precision, false), do: Number.Delimit.number_to_delimited(number, precision: precision) def fixed(_ctx, number, precision, true), do: Number.Delimit.number_to_delimited(number, precision: precision, delimiter: ",", separator: "." ) @doc """ Returns the first characters in a text string. This is Unicode safe. """ @expression_doc expression: "left(\"foobar\", 4)", result: "foob" @expression_doc expression: "left(\"Умерла Мадлен Олбрайт - первая женщина на посту главы Госдепа США\", 20)", result: "Умерла Мадлен Олбрай" def left(_ctx, binary, size) do String.slice(binary, 0, size) end @doc """ Returns the number of characters in a text string """ @expression_doc expression: "len(\"foo\")", result: 3 @expression_doc expression: "len(\"zoë\")", result: 3 def len(_ctx, binary) do String.length(binary) end @doc """ Converts a text string to lowercase """ @expression_doc expression: "lower(\"Foo Bar\")", result: "foo bar" def lower(_ctx, binary) do String.downcase(binary) end @doc """ Capitalizes the first letter of every word in a text string """ @expression_doc expression: "proper(\"foo bar\")", result: "Foo Bar" def proper(_ctx, binary) do binary |> String.split(" ") |> Enum.map_join(" ", &String.capitalize/1) end @doc """ Repeats text a given number of times """ @expression_doc expression: "rept(\"*\", 10)", result: "**********" def rept(_ctx, value, amount) do String.duplicate(value, amount) end @doc """ Returns the last characters in a text string. This is Unicode safe. """ @expression_doc expression: "right(\"testing\", 3)", result: "ing" @expression_doc expression: "right(\"Умерла Мадлен Олбрайт - первая женщина на посту главы Госдепа США\", 20)", result: "ту главы Госдепа США" 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 """ @expression_doc expression: "substitute(\"I can't\", \"can't\", \"can do\")", result: "I can do" def substitute(_ctx, subject, pattern, replacement) do String.replace(subject, pattern, replacement) end @doc """ Returns the unicode character specified by a number """ @expression_doc expression: "unichar(65)", result: "A" @expression_doc expression: "unichar(233)", result: "é" def unichar(_ctx, code) do <> end @doc """ Returns a numeric code for the first character in a text string """ @expression_doc expression: "unicode(\"A\")", result: 65 @expression_doc expression: "unicode(\"é\")", result: 233 def unicode(_ctx, <>) do code end @doc """ Converts a text string to uppercase """ @expression_doc expression: "upper(\"foo\")", result: "FOO" def upper(_ctx, binary) do String.upcase(binary) end @doc """ Returns the first word in the given text - equivalent to WORD(text, 1) """ @expression_doc expression: "first_word(\"foo bar baz\")", result: "foo" def first_word(_ctx, binary) do [word | _] = String.split(binary, " ") word end @doc """ Formats a number as a percentage """ @expression_doc expression: "percent(2/10)", result: "20%" @expression_doc expression: "percent(0.2)", result: "20%" @expression_doc expression: "percent(d)", context: %{"d" => "0.2"}, result: "20%" def percent(_ctx, float) do with float when is_number(float) <- parse_float(float) do Number.Percentage.number_to_percentage(float * 100, precision: 0) end end @doc """ Formats digits in text for reading in TTS """ @expression_doc expression: "read_digits(\"+271\")", result: "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 """ @expression_doc expression: "remove_first_word(\"foo bar\")", result: "bar" @expression_doc expression: "remove_first_word(\"foo-bar\", \"-\")", result: "bar" def remove_first_word(_ctx, binary) do separator = " " tl(String.split(binary, separator)) |> Enum.join(separator) end 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 """ @expression_doc expression: "word(\"hello cow-boy\", 2)", result: "cow" @expression_doc expression: "word(\"hello cow-boy\", 2, true)", result: "cow-boy" @expression_doc expression: "word(\"hello cow-boy\", -1)", result: "boy" def word(_ctx, binary, n) do parts = String.split(binary, @punctuation_pattern) # This slicing seems off. [part] = if n < 0 do Enum.slice(parts, n, 1) else Enum.slice(parts, n - 1, 1) end part end 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("one two three") words You entered 3 words ``` """ @expression_doc expression: "word_count(\"hello cow-boy\")", result: 3 @expression_doc expression: "word_count(\"hello cow-boy\", true)", result: 2 def word_count(_ctx, binary) do binary |> String.split(@punctuation_pattern) |> Enum.count() end 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 """ @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 2, 4)", result: "expressions are" @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 2)", result: "expressions are fun" @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 1, -2)", result: "FLOIP expressions" @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", -1)", result: "fun" def word_slice(_ctx, binary, start) do parts = binary |> String.split(" ") cond do start > 0 -> parts |> Enum.slice(start - 1, length(parts)) |> Enum.join(" ") start < 0 -> parts |> Enum.slice(start..length(parts)) |> Enum.join(" ") end end def word_slice(_ctx, binary, start, stop) do cond do stop > 0 -> binary |> String.split(@punctuation_pattern) |> Enum.slice((start - 1)..(stop - 2)) |> Enum.join(" ") stop < 0 -> binary |> String.split(@punctuation_pattern) |> Enum.slice((start - 1)..(stop - 1)) |> Enum.join(" ") end end def word_slice(_ctx, binary, start, stop, by_spaces) do splitter = if(by_spaces, do: " ", else: @punctuation_pattern) case stop do stop when stop > 0 -> binary |> String.split(splitter) |> Enum.slice((start - 1)..(stop - 2)) |> Enum.join(" ") stop when stop < 0 -> binary |> String.split(splitter) |> Enum.slice((start - 1)..(stop - 1)) |> Enum.join(" ") end end @doc """ Returns `true` if the argument is a number. """ @expression_doc expression: "isnumber(1)", result: true @expression_doc expression: "isnumber(1.0)", result: true @expression_doc expression: "isnumber(\"1.0\")", result: true @expression_doc expression: "isnumber(\"a\")", result: false def isnumber(_ctx, var) do case var do var when is_float(var) or is_integer(var) -> true var when is_binary(var) -> String.match?(var, ~r/^\d+?.?\d+$/) _var -> false end end @doc """ Returns `true` if the argument is a boolean. """ @expression_doc expression: "isbool(true)", result: true @expression_doc expression: "isbool(false)", result: true @expression_doc expression: "isbool(1)", result: false @expression_doc expression: "isbool(0)", result: false @expression_doc expression: "isbool(\"true\")", result: false @expression_doc expression: "isbool(\"false\")", result: false def isbool(_ctx, var) do var in [true, false] end @doc """ Returns `true` if the argument is a string. """ @expression_doc expression: "isstring(\"hello\")", result: true @expression_doc expression: "isstring(false)", result: false @expression_doc expression: "isstring(1)", result: 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. """ @expression_doc expression: "has_all_words(\"the quick brown FOX\", \"the fox\")", result: true @expression_doc expression: "has_all_words(\"the quick brown FOX\", \"red fox\")", result: false def has_all_words(_ctx, haystack, words) do {patterns, results} = search_words(haystack, words) # future match result: Enum.join(results, " ") 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. """ @expression_doc expression: "has_any_word(\"The Quick Brown Fox\", \"fox quick\")", result: %{"__value__" => true, "match" => "Quick Fox"} @expression_doc expression: "has_any_word(\"The Quick Brown Fox\", \"yellow\")", result: %{"__value__" => false, "match" => nil} def has_any_word(_ctx, haystack, words) do haystack_words = String.split(haystack) haystacks_lowercase = Enum.map(haystack_words, &String.downcase/1) words_lowercase = String.split(words) |> Enum.map(&String.downcase/1) matched_indices = haystacks_lowercase |> Enum.with_index() |> Enum.filter(fn {haystack_word, _index} -> Enum.member?(words_lowercase, haystack_word) end) |> Enum.map(fn {_haystack_word, index} -> index end) matched_haystack_words = Enum.map(matched_indices, &Enum.at(haystack_words, &1)) match? = Enum.any?(matched_haystack_words) %{ "__value__" => match?, "match" => if(match?, do: Enum.join(matched_haystack_words, " "), else: nil) } end @doc """ Tests whether text starts with beginning Both text values are trimmed of surrounding whitespace, but otherwise matching is strict without any tokenization. """ @expression_doc expression: "has_beginning(\"The Quick Brown\", \"the quick\")", result: true @expression_doc expression: "has_beginning(\"The Quick Brown\", \"the quick\")", result: false @expression_doc expression: "has_beginning(\"The Quick Brown\", \"quick brown\")", result: false def has_beginning(_ctx, text, beginning) do case Regex.run(~r/^#{Regex.escape(beginning)}/i, text) do # future match result: first [_first | _remainder] -> true nil -> false end end @doc """ Tests whether `expression` contains a date formatted according to our environment This is very naively implemented with a regular expression. """ @expression_doc expression: "has_date(\"the date is 15/01/2017\")", result: true @expression_doc expression: "has_date(\"there is no date here, just a year 2017\")", result: false def has_date(_ctx, expression) do !!DateHelpers.extract_dateish(expression) end @doc """ Tests whether `expression` is a date equal to `date_string` """ @expression_doc expression: "has_date_eq(\"the date is 15/01/2017\", \"2017-01-15\")", result: true @expression_doc expression: "has_date_eq(\"there is no date here, just a year 2017\", \"2017-01-15\")", result: false def has_date_eq(_ctx, expression, date_string) do found_date = DateHelpers.extract_dateish(expression) test_date = DateHelpers.extract_dateish(date_string) # Future match result: found_date found_date == test_date end @doc """ Tests whether `expression` is a date after the date `date_string` """ @expression_doc expression: "has_date_gt(\"the date is 15/01/2017\", \"2017-01-01\")", result: true @expression_doc expression: "has_date_gt(\"the date is 15/01/2017\", \"2017-03-15\")", result: false def has_date_gt(_ctx, expression, date_string) do found_date = DateHelpers.extract_dateish(expression) test_date = DateHelpers.extract_dateish(date_string) # future match result: found_date Date.compare(found_date, test_date) == :gt end @doc """ Tests whether `expression` contains a date before the date `date_string` """ @expression_doc expression: "has_date_lt(\"the date is 15/01/2017\", \"2017-06-01\")", result: true @expression_doc expression: "has_date_lt(\"the date is 15/01/2021\", \"2017-03-15\")", result: false def has_date_lt(_ctx, expression, date_string) do found_date = DateHelpers.extract_dateish(expression) test_date = DateHelpers.extract_dateish(date_string) # future match result: found_date Date.compare(found_date, test_date) == :lt end @doc """ Tests whether an email is contained in text """ @expression_doc expression: "has_email(\"my email is foo1@bar.com, please respond\")", result: true @expression_doc expression: "has_email(\"i'm not sharing my email\")", result: 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 | _] -> true nil -> false end end @doc """ Returns whether the contact is part of group with the passed in UUID """ @expression_doc expression: "has_group(contact.groups, \"b7cf0d83-f1c9-411c-96fd-c511a4cfa86d\")", context: %{ "contact" => %{ "groups" => [ %{ "uuid" => "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d" } ] } }, result: true @expression_doc expression: "has_group(contact.groups, \"00000000-0000-0000-0000-000000000000\")", context: %{ "contact" => %{ "groups" => [ %{ "uuid" => "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d" } ] } }, result: false def has_group(_ctx, groups, uuid) do group = Enum.find(groups, nil, &(&1["uuid"] == uuid)) # future match result: group !!group end defp extract_numberish(value) when is_number(value), do: value defp extract_numberish(expression) do with [match] <- Regex.run(~r/([0-9]+\.?[0-9]*)/u, replace_arabic_numerals(expression), capture: :first), float <- parse_float(match) do float else # Regex can return nil nil -> nil # Float parsing can return :error :error -> nil end end defp replace_arabic_numerals(expression) when is_binary(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 def parse_float(number) when is_number(number), do: number def parse_float(binary) when is_binary(binary) do case Float.parse(binary) do {float, ""} -> float _ -> nil end end @doc """ Tests whether `expression` contains a number """ @expression_doc expression: "has_number(\"the number is 42 and 5\")", result: true @expression_doc expression: "has_number(\"العدد ٤٢\")", result: true @expression_doc expression: "has_number(\"٠.٥\")", result: true @expression_doc expression: "has_number(\"0.6\")", result: true def has_number(_ctx, expression) do number = extract_numberish(expression) # future match result: number !!number end @doc """ Tests whether `expression` contains a number equal to the value """ @expression_doc expression: "has_number_eq(\"the number is 42\", 42)", result: true @expression_doc expression: "has_number_eq(\"the number is 42\", 42.0)", result: true @expression_doc expression: "has_number_eq(\"the number is 42\", \"42\")", result: true @expression_doc expression: "has_number_eq(\"the number is 42.0\", \"42\")", result: true @expression_doc expression: "has_number_eq(\"the number is 40\", \"42\")", result: false @expression_doc expression: "has_number_eq(\"the number is 40\", \"foo\")", result: false @expression_doc expression: "has_number_eq(\"four hundred\", \"foo\")", result: false def has_number_eq(_ctx, expression, float) do with number when is_number(number) <- extract_numberish(expression), float when is_number(float) <- parse_float(float) do # Future match result: number float == number else nil -> false :error -> false end end @doc """ Tests whether `expression` contains a number greater than min """ @expression_doc expression: "has_number_gt(\"the number is 42\", 40)", result: true @expression_doc expression: "has_number_gt(\"the number is 42\", 40.0)", result: true @expression_doc expression: "has_number_gt(\"the number is 42\", \"40\")", result: true @expression_doc expression: "has_number_gt(\"the number is 42.0\", \"40\")", result: true @expression_doc expression: "has_number_gt(\"the number is 40\", \"40\")", result: false @expression_doc expression: "has_number_gt(\"the number is 40\", \"foo\")", result: false @expression_doc expression: "has_number_gt(\"four hundred\", \"foo\")", result: false def has_number_gt(_ctx, expression, float) do with number when is_number(number) <- extract_numberish(expression), float when is_number(float) <- parse_float(float) do # Future match result: number number > float else nil -> false :error -> false end end @doc """ Tests whether `expression` contains a number greater than or equal to min """ @expression_doc expression: "has_number_gte(\"the number is 42\", 42)", result: true @expression_doc expression: "has_number_gte(\"the number is 42\", 42.0)", result: true @expression_doc expression: "has_number_gte(\"the number is 42\", \"42\")", result: true @expression_doc expression: "has_number_gte(\"the number is 42.0\", \"45\")", result: false @expression_doc expression: "has_number_gte(\"the number is 40\", \"45\")", result: false @expression_doc expression: "has_number_gte(\"the number is 40\", \"foo\")", result: false @expression_doc expression: "has_number_gte(\"four hundred\", \"foo\")", result: false def has_number_gte(_ctx, expression, float) do with number when is_number(number) <- extract_numberish(expression), float when is_number(float) <- parse_float(float) do # Future match result: number number >= float else nil -> false :error -> false end end @doc """ Tests whether `expression` contains a number less than max """ @expression_doc expression: "has_number_lt(\"the number is 42\", 44)", result: true @expression_doc expression: "has_number_lt(\"the number is 42\", 44.0)", result: true @expression_doc expression: "has_number_lt(\"the number is 42\", \"40\")", result: false @expression_doc expression: "has_number_lt(\"the number is 42.0\", \"40\")", result: false @expression_doc expression: "has_number_lt(\"the number is 40\", \"40\")", result: false @expression_doc expression: "has_number_lt(\"the number is 40\", \"foo\")", result: false @expression_doc expression: "has_number_lt(\"four hundred\", \"foo\")", result: false def has_number_lt(_ctx, expression, float) do with number when is_number(number) <- extract_numberish(expression), float when is_number(float) <- parse_float(float) do # Future match result: number number < float else nil -> false :error -> false end end @doc """ Tests whether `expression` contains a number less than or equal to max """ @expression_doc expression: "has_number_lte(\"the number is 42\", 42)", result: true @expression_doc expression: "has_number_lte(\"the number is 42\", 42.0)", result: true @expression_doc expression: "has_number_lte(\"the number is 42\", \"42\")", result: true @expression_doc expression: "has_number_lte(\"the number is 42.0\", \"40\")", result: false @expression_doc expression: "has_number_lte(\"the number is 40\", \"foo\")", result: false @expression_doc expression: "has_number_lte(\"four hundred\", \"foo\")", result: false @expression_doc expression: "has_number_lte(response, 5)", context: %{"response" => 3}, result: true def has_number_lte(_ctx, expression, float) do with number when is_number(number) <- extract_numberish(expression), float when is_number(float) <- parse_float(float) do # Future match result: number number <= float else nil -> false :error -> false end end @doc """ Tests whether the text contains only phrase The phrase must be the only text in the text to match """ @expression_doc expression: "has_only_phrase(\"Quick Brown\", \"quick brown\")", result: true @expression_doc expression: "has_only_phrase(\"\", \"\")", result: true @expression_doc expression: "has_only_phrase(\"The Quick Brown Fox\", \"quick brown\")", result: false def has_only_phrase(_ctx, expression, phrase) do case Enum.map([expression, phrase], &String.downcase/1) do # Future match result: expression [same, same] -> true _anything_else -> 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. """ @expression_doc expression: "has_only_text(\"foo\", \"foo\")", result: true @expression_doc expression: "has_only_text(\"\", \"\")", result: true @expression_doc expression: "has_only_text(\"foo\", \"FOO\")", result: false def has_only_text(_ctx, expression_one, expression_two) do expression_one == expression_two end @doc """ Tests whether `expression` matches the regex pattern Both text values are trimmed of surrounding whitespace and matching is case-insensitive. """ @expression_doc expression: "has_pattern(\"Buy cheese please\", \"buy (\\w+)\")", result: true @expression_doc expression: "has_pattern(\"Sell cheese please\", \"buy (\\w+)\")", result: 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 true else _ -> false end end @doc """ Tests whether `expression` contains a phone number. The optional country_code argument specifies the country to use for parsing. """ @expression_doc expression: "has_phone(\"my number is +12067799294 thanks\")", result: true @expression_doc expression: "has_phone(\"my number is 2067799294 thanks\", \"US\")", result: true @expression_doc expression: "has_phone(\"my number is 206 779 9294 thanks\", \"US\")", result: true @expression_doc expression: "has_phone(\"my number is none of your business\", \"US\")", result: false def has_phone(_ctx, expression) do letters_removed = Regex.replace(~r/[a-z]/i, expression, "") case ExPhoneNumber.parse(letters_removed, "") do # Future match result: ExPhoneNumber.format(pn, :es164) {:ok, _pn} -> true _ -> false end end def has_phone(_ctx, 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} -> true _ -> 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. """ @expression_doc expression: "has_phrase(\"the quick brown fox\", \"brown fox\")", result: true @expression_doc expression: "has_phrase(\"the quick brown fox\", \"quick fox\")", result: false @expression_doc expression: "has_phrase(\"the quick brown fox\", \"\")", result: 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 found? end @doc """ Tests whether there the `expression` has any characters in it """ @expression_doc expression: "has_text(\"quick brown\")", result: true @expression_doc expression: "has_text(\"\")", result: false @expression_doc expression: "has_text(\" \n\")", result: false @expression_doc expression: "has_text(123)", result: true def has_text(_ctx, expression) do expression = to_string(expression) String.trim(expression) != "" end @doc """ Tests whether `expression` contains a time. """ @expression_doc expression: "has_time(\"the time is 10:30\")", result: %{"__value__" => true, "match" => ~T[10:30:00]} @expression_doc expression: "has_time(\"the time is 10:00 pm\")", result: %{"__value__" => true, "match" => ~T[10:00:00]} @expression_doc expression: "has_time(\"the time is 10:30:45\")", result: %{"__value__" => true, "match" => ~T[10:30:45]} @expression_doc expression: "has_time(\"there is no time here, just the number 25\")", result: false def has_time(_ctx, expression) do if time = DateHelpers.extract_timeish(expression) do %{ "__value__" => true, "match" => time } else false end end @doc """ map over a list of items and apply the mapper function to every item, returning the result. """ @expression_doc doc: "Map over the range of numbers, create a date in January for every number", expression: "map(1..3, &date(2022, 1, &1))", result: [~D[2022-01-01], ~D[2022-01-02], ~D[2022-01-03]] @expression_doc doc: "Map over the range of numbers, multiple each by itself and return the result", expression: "map(1..3, &(&1 * &1))", result: [1, 4, 9] def map(_ctx, enumerable, mapper) do Enum.map(enumerable, mapper) end @doc """ Return the division remainder of two integers. """ @expression_doc expression: "rem(4, 2)", result: 0 @expression_doc expression: "rem(85, 3)", result: 1 def rem(_ctx, integer1, integer2) do rem(integer1, integer2) end end