defmodule Oban.Web.CronExpr do @moduledoc false @days_of_week_names %{ 0 => "Sunday", 1 => "Monday", 2 => "Tuesday", 3 => "Wednesday", 4 => "Thursday", 5 => "Friday", 6 => "Saturday" } @days_of_week_translations %{ "SUN" => 0, "MON" => 1, "TUE" => 2, "WED" => 3, "THU" => 4, "FRI" => 5, "SAT" => 6 } @doc """ Convert a cron expression into a human-readable description. Returns a friendly string for common patterns, or nil for complex patterns that don't match known templates. """ def describe(expression) when is_binary(expression) do case expression do "@yearly" -> "Yearly on January 1st" "@annually" -> "Yearly on January 1st" "@monthly" -> "Monthly on the 1st" "@weekly" -> "Weekly on Sunday" "@daily" -> "Daily at midnight" "@midnight" -> "Daily at midnight" "@hourly" -> "Every hour" "@reboot" -> "At system reboot" _ -> describe_parsed(expression) end end def describe(_), do: nil defp describe_parsed(expression) do with [min, hrs, dom, "*", dow] <- String.split(expression, " ", parts: 5), {:ok, parsed_min} <- parse_field(min, 0..59), {:ok, parsed_hrs} <- parse_field(hrs, 0..23), {:ok, parsed_dom} <- parse_field(dom, 1..31), {:ok, parsed_dow} <- parse_field(dow, 0..7, @days_of_week_translations) do combine_description(parsed_min, parsed_hrs, parsed_dom, parsed_dow) else _ -> nil end end # Field Parsing defp parse_field(field, bounds, translations \\ %{}) do parts = field |> String.split(",") |> Enum.map(&parse_part(&1, bounds, translations)) if Enum.any?(parts, &(&1 == :error)) do :error else {:ok, parts} end end defp parse_part("*", _bounds, _translations), do: :wildcard defp parse_part("*/" <> step, bounds, _translations) do case Integer.parse(step) do {num, ""} when num > bounds.first and num <= bounds.last -> {:step, num} _ -> :error end end defp parse_part(part, bounds, translations) do if String.contains?(part, "-") do parse_range(part, bounds, translations) else parse_value(part, bounds, translations) end end defp parse_range(part, bounds, translations) do with [start_str, end_str] <- String.split(part, "-", parts: 2), {:ok, start_val} <- translate_or_parse(start_str, translations), {:ok, end_val} <- translate_or_parse(end_str, translations), true <- start_val in bounds and end_val in bounds and end_val >= start_val do {:range, start_val, end_val} else _ -> :error end end defp parse_value(part, bounds, translations) do with {:ok, val} <- translate_or_parse(part, translations), true <- val in bounds do {:value, val} else _ -> :error end end defp translate_or_parse(str, translations) do upper = String.upcase(str) with :error <- Map.fetch(translations, upper) do case Integer.parse(str) do {num, ""} -> {:ok, num} _ -> :error end end end # Description Combination defp combine_description(min, hrs, dom, dow) do if dom == [:wildcard] and dow == [:wildcard] do describe_time(min, hrs) else combine_date_time(min, hrs, dom, dow) end end defp describe_time([:wildcard], [:wildcard]), do: "Every minute" defp describe_time([{:step, 1}], [:wildcard]), do: "Every minute" defp describe_time([{:step, step}], [:wildcard]), do: "Every #{step} minutes" defp describe_time([{:value, 0}], [:wildcard]), do: "Every hour" defp describe_time([{:value, 0}], [{:step, 1}]), do: "Every hour" defp describe_time([{:value, 0}], [{:step, step}]), do: "Every #{step} hours" defp describe_time([{:value, min}], [{:step, step}]) when min in 0..59 do "Every #{step} hours at :#{String.pad_leading("#{min}", 2, "0")}" end defp describe_time([{:value, 0}], [{:value, 0}]), do: "Daily at midnight" defp describe_time([{:value, 0}], [{:value, 12}]), do: "Daily at noon" defp describe_time([{:value, min}], [{:value, hr}]) when min in 0..59 and hr in 0..23 do "Daily at #{format_time_24h(hr, min)}" end defp describe_time([{:value, min}], hours) when is_list(hours) and min in 0..59 do times = Enum.map(hours, fn {:value, hr} when hr in 0..23 -> format_time_24h(hr, min) _ -> nil end) if Enum.any?(times, &is_nil/1) do nil else "Daily at #{format_list(times)}" end end defp describe_time(_, _), do: nil defp combine_date_time(min, hrs, dom, dow) do time = extract_time(min, hrs) date_desc = describe_date(dom, dow) case {time, date_desc} do {nil, _} -> nil {_, nil} -> nil {{0, 0}, desc} -> "#{desc} at 0:00" {{hrs, min}, desc} -> "#{desc} at #{format_time_24h(hrs, min)}" end end defp extract_time([{:value, min}], [{:value, hrs}]) when min in 0..59 and hrs in 0..23 do {hrs, min} end defp extract_time(_, _), do: nil # Date Description (DOM and DOW combination) defp describe_date([:wildcard], dow) do describe_dow(dow) end defp describe_date(dom, [:wildcard]) do describe_dom(dom) end defp describe_date(dom, dow) do describe_combined_dom_dow(dom, dow) end # Day of Month Description defp describe_dom([{:value, day}]) when day in 1..31 do "Monthly on the #{ordinal(day)}" end defp describe_dom(parts) when is_list(parts) do values = expand_dom_parts(parts) case excluded_dom(values) do {:ok, day} -> "Daily except the #{ordinal(day)}" :error -> days = extract_dom_values(parts) if days != [] do "On the #{format_list(Enum.map(days, &ordinal/1))}" else nil end end end defp describe_dom(_), do: nil defp excluded_dom(values) do case Enum.to_list(1..31) -- values do [excluded] -> {:ok, excluded} _ -> :error end end defp expand_dom_parts(parts) do parts |> Enum.flat_map(fn {:value, val} -> [val] {:range, start_val, end_val} -> Enum.to_list(start_val..end_val) _ -> [] end) |> Enum.sort() end defp extract_dom_values(parts) do Enum.flat_map(parts, fn {:value, val} when val in 1..31 -> [val] {:range, start_val, end_val} when start_val in 1..31 and end_val in 1..31 -> Enum.to_list(start_val..end_val) _ -> [] end) end # Day of Week Description defp describe_dow([{:value, day}]) when day in 0..7 do "Weekly on #{day_name(normalize_dow(day))}" end defp describe_dow(parts) when is_list(parts) do values = expand_dow_parts(parts) cond do values == [1, 2, 3, 4, 5] -> "Weekdays" values == [0, 6] -> "Weekends" length(values) == 6 -> {:ok, day} = excluded_dow(values) "Daily except #{day_name(day)}s" values != [] -> "On #{format_list(Enum.map(values, &day_name/1))}" true -> nil end end defp describe_dow(_), do: nil defp expand_dow_parts(parts) do parts |> Enum.flat_map(fn {:value, val} -> [normalize_dow(val)] {:range, start_val, end_val} -> Enum.map(start_val..end_val, &normalize_dow/1) _ -> [] end) |> Enum.uniq() |> Enum.sort() end defp normalize_dow(7), do: 0 defp normalize_dow(day), do: day defp excluded_dow(values) do case Enum.to_list(0..6) -- values do [excluded] -> {:ok, excluded} _ -> :error end end # Combined DOM and DOW description defp describe_combined_dom_dow(dom, dow) do dom_values = expand_dom_parts(dom) dow_values = expand_dow_parts(dow) case {dom_values, excluded_dom(dom_values), dow_values, excluded_dow(dow_values)} do {[dom_day], _, [dow_day], _} -> "The #{ordinal(dom_day)}, only on #{day_name(dow_day)}s" {[dom_day], _, _, {:ok, excluded}} -> "The #{ordinal(dom_day)}, except #{day_name(excluded)}s" {_, {:ok, excluded}, [dow_day], _} -> "#{day_name(dow_day)}s, except the #{ordinal(excluded)}" {_, {:ok, dom_excluded}, _, {:ok, dow_excluded}} -> "Daily except the #{ordinal(dom_excluded)} and #{day_name(dow_excluded)}s" _ -> nil end end # Helper functions defp day_name(num), do: Map.get(@days_of_week_names, num, "Unknown") defp format_time_24h(hr, min) do "#{hr}:#{String.pad_leading("#{min}", 2, "0")}" end defp format_list([single]), do: single defp format_list([first, second]), do: "#{first} and #{second}" defp format_list(items) when length(items) > 2 do {init, [last]} = Enum.split(items, -1) "#{Enum.join(init, ", ")}, and #{last}" end defp format_list(_), do: nil defp ordinal(num) when num in [1, 21, 31], do: "#{num}st" defp ordinal(num) when num in [2, 22], do: "#{num}nd" defp ordinal(num) when num in [3, 23], do: "#{num}rd" defp ordinal(num), do: "#{num}th" end