Utility functions for converting frontend schedule dictionaries to cron strings and vice versa. See http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html#format for details on cron format.

(ns metabase.util.cron
  (:require
   [clojure.string :as str]
   [metabase.util.i18n :as i18n]
   [metabase.util.malli :as mu]
   [metabase.util.malli.registry :as mr]
   [metabase.util.malli.schema :as ms])
  (:import
   (net.redhogs.cronparser CronExpressionDescriptor)
   (org.quartz CronExpression)))
(set! *warn-on-reflection* true)
(mr/def ::CronScheduleString
  (mu/with-api-error-message
   [:and
    ms/NonBlankString
    [:fn
     {:error/fn (fn [{:keys [value]} _]
                  (try
                    (CronExpression/validateExpression value)
                    (catch Throwable e
                      (str "Invalid cron schedule string: " (.getMessage e)))))}
     (fn [^String s]
       (try
         (CronExpression/validateExpression s)
         true
         (catch Throwable _
           false)))]]
   (i18n/deferred-tru "value must be a valid Quartz cron schedule string.")))

Malli Schema for a valid cron schedule string.

(def CronScheduleString
  [:ref ::CronScheduleString])
(mr/def ::CronHour
  [:int {:min 0, :max 23}])
(mr/def ::CronMinute
  [:int {:min 0, :max 59}])
(mr/def ::ScheduleMap
  (mu/with-api-error-message
   [:map
    {:error/message "Expanded schedule map"}
    [:schedule_type                    [:enum "hourly" "daily" "weekly" "monthly"]]
    [:schedule_day    {:optional true} [:maybe [:enum "sun" "mon" "tue" "wed" "thu" "fri" "sat"]]]
    [:schedule_frame  {:optional true} [:maybe [:enum "first" "mid" "last"]]]
    [:schedule_hour   {:optional true} [:maybe ::CronHour]]
    [:schedule_minute {:optional true} [:maybe ::CronMinute]]]
   (i18n/deferred-tru "value must be a valid schedule map. See schema in metabase.util.cron for details.")))

Schema for a frontend-parsable schedule map. Used for Pulses and DB scheduling.

(def ScheduleMap
  [:ref ::ScheduleMap])

+----------------------------------------------------------------------------------------------------------------+ | SCHEDULE MAP -> CRON STRING | +----------------------------------------------------------------------------------------------------------------+

(mu/defn- cron-string :- CronScheduleString
  "Build a cron string from key-value pair parts."
  [{:keys [seconds minutes hours day-of-month month day-of-week year]}]
  (str/join " " [(or seconds      "0")
                 (or minutes      "0")
                 (or hours        "*")
                 (or day-of-month "*")
                 (or month        "*")
                 (or day-of-week  "?")
                 (or year         "*")]))
(def ^:private day-of-week->cron
  {"sun"  1
   "mon"  2
   "tue"  3
   "wed"  4
   "thu"  5
   "fri"  6
   "sat"  7})
(defn- frame->cron [frame day-of-week]
  (if day-of-week
    ;; specific days of week like Mon or Fri
    (assoc {:day-of-month "?"}
           :day-of-week (case frame
                          "first" (str (day-of-week->cron day-of-week) "#1")
                          "last"  (str (day-of-week->cron day-of-week) "L")))
    ;; specific CALENDAR DAYS like 1st or 15th
    (assoc {:day-of-week "?"}
           :day-of-month (case frame
                           "first" "1"
                           "mid"   "15"
                           "last"  "L"))))
(mu/defn schedule-map->cron-string :- CronScheduleString
  "Convert the frontend schedule map into a cron string."
  [{day-of-week :schedule_day, hour :schedule_hour, minute :schedule_minute,
    frame :schedule_frame,  schedule-type :schedule_type} :- ScheduleMap]
  (cron-string (case (keyword schedule-type)
                 :hourly  {:minutes minute}
                 :daily   {:hours (or hour 0)}
                 :weekly  {:hours       hour
                           :day-of-week (day-of-week->cron day-of-week)
                           :day-of-month "?"}
                 :monthly (assoc (frame->cron frame day-of-week)
                                 :hours hour))))

+----------------------------------------------------------------------------------------------------------------+ | CRON STRING -> SCHEDULE MAP | +----------------------------------------------------------------------------------------------------------------+

(defn- cron->day-of-week [day-of-week]
  (when-let [[_ day-of-week] (re-matches #"(^\d).*$" day-of-week)]
    (case day-of-week
      "1" "sun"
      "2" "mon"
      "3" "tue"
      "4" "wed"
      "5" "thu"
      "6" "fri"
      "7" "sat")))
(defn- cron-day-of-week+day-of-month->frame [day-of-week day-of-month]
  (cond
    (re-matches #"^\d#1$" day-of-week) "first"
    (re-matches #"^\dL$"  day-of-week) "last"
    (= day-of-month "1")               "first"
    (= day-of-month "15")              "mid"
    (= day-of-month "L")               "last"
    :else                              nil))
(defn- cron->digit [digit]
  (when (and digit
             (not= digit "*"))
    (Integer/parseInt digit)))
(defn- cron->schedule-type [hours day-of-month day-of-week]
  (cond
    (and day-of-month
         (not= day-of-month "*")
         (or (= day-of-week "?")
             (re-matches #"^\d#1$" day-of-week)
             (re-matches #"^\dL$"  day-of-week))) "monthly"
    (and day-of-week
         (not= day-of-week "?"))                  "weekly"
    (and hours
         (not= hours "*"))                        "daily"
    :else                                         "hourly"))
(mu/defn cron-string->schedule-map :- ScheduleMap
  "Convert a normal `cron-string` into the expanded ScheduleMap format used by the frontend."
  [cron-string :- CronScheduleString]
  (let [[_ mins hours day-of-month _ day-of-week _] (str/split cron-string #"\s+")]
    {:schedule_minute (cron->digit mins)
     :schedule_day    (cron->day-of-week day-of-week)
     :schedule_frame  (cron-day-of-week+day-of-month->frame day-of-week day-of-month)
     :schedule_hour   (cron->digit hours)
     :schedule_type   (cron->schedule-type hours day-of-month day-of-week)}))
(mu/defn describe-cron-string :- ms/NonBlankString
  "Return a human-readable description of a cron expression, localized for the current User."
  [^String cron-string :- CronScheduleString]
  (CronExpressionDescriptor/getDescription cron-string (i18n/user-locale)))