Logic for rendering datetimes when context such as timezone, column metadata, and visualization settings are known. | (ns metabase.formatter.datetime (:require [clojure.string :as str] [java-time.api :as t] [metabase.models.visualization-settings :as mb.viz] [metabase.public-settings :as public-settings] [metabase.query-processor.streaming.common :as common] [metabase.util.date-2 :as u.date] [metabase.util.formatting.constants :as constants] [metabase.util.log :as log]) (:import (com.ibm.icu.text RuleBasedNumberFormat) (java.util Locale))) |
(set! *warn-on-reflection* true) | |
Returns
| (defn temporal-string?
[s]
(boolean
(try
(u.date/parse s)
(catch Exception _e false)))) |
(defn- reformat-temporal-str [timezone-id s new-format-string] (t/format new-format-string (u.date/parse s timezone-id))) | |
(defn- day-of-week
[n abbreviate]
(let [fmtr (java.time.format.DateTimeFormatter/ofPattern (if abbreviate "EEE" "EEEE"))]
(.format fmtr (java.time.DayOfWeek/of n)))) | |
(defn- month-of-year
[n abbreviate]
(let [fmtr (java.time.format.DateTimeFormatter/ofPattern (if abbreviate "MMM" "MMMM"))]
(.format fmtr (java.time.Month/of n)))) | |
Format an integer as x-th of y, for example, 2nd week of year. | (defn- x-of-y
[n]
(let [nf (RuleBasedNumberFormat. (Locale. (public-settings/site-locale)) RuleBasedNumberFormat/ORDINAL)]
(.format nf n))) |
(defn- hour-of-day
[s time-style]
(let [n (parse-long s)
ts (u.date/parse "2022-01-01-00:00:00")]
(u.date/format time-style (t/plus ts (t/hours n))))) | |
Get the column-settings map for the given column from the viz-settings. | (defn- viz-settings-for-col
[{column-name :name :keys [field_ref]} viz-settings]
(let [[_ field-id-or-name] field_ref
all-cols-settings (-> viz-settings
::mb.viz/column-settings
;; update the keys so that they will have only the :field-id or :column-name
;; and not have any metadata. Since we don't know the metadata, we can never
;; match a key with metadata, even if we do have the correct name or id
(update-keys #(select-keys % [::mb.viz/field-id ::mb.viz/column-name])))]
(or (all-cols-settings {::mb.viz/field-id field-id-or-name})
(all-cols-settings {::mb.viz/column-name field-id-or-name})
(all-cols-settings {::mb.viz/column-name column-name})))) |
Given viz-settings with a time-style and possible time-enabled (precision) entry, create the format string.
Note that if the | (defn- determine-time-format
[{:keys [time-style] :or {time-style "h:mm A"} :as viz-settings}]
;; NOTE - If :time-enabled is present but nil it will return nil
(when-some [base-time-format (case (get viz-settings :time-enabled "minutes")
"minutes" "mm"
"seconds" "mm:ss"
"milliseconds" "mm:ss.SSS"
nil nil)]
(case time-style
"HH:mm" (format "HH:%s" base-time-format)
;; Deprecated time style which should be already converted to HH:mm when viz settings are
;; normalized, but we'll handle it here too just in case. (#18112)
"k:mm" (str "h" base-time-format)
("h:mm A" "h:mm a") (format "h:%s a" base-time-format)
time-style))) |
The Java pattern for DateTimeFormatter is | (defn- fix-time-style [time-style default-time-style] (str/replace (or time-style default-time-style) #"A" "a")) |
Potentially modify a date style to abbreviate names or add a different date separator. | (defn- post-process-date-style
[date-style {:keys [date-abbreviate date-separator]}]
(let [conditional-changes
(cond-> (-> date-style (str/replace #"dddd" "EEEE"))
date-separator (str/replace #"/" date-separator)
date-abbreviate (-> (str/replace #"MMMM" "MMM")
(str/replace #"EEEE" "EEE")
(str/replace #"DDD" "D")))]
(-> conditional-changes
;; 'D' formats as Day of year, we want Day of month, which is 'd' (issue #27469)
(str/replace #"D" "d")
;; 'YYYY' formats as 'week-based-year', we want 'yyyy' which formats by 'year-of-era'
;; aka 'day-based-year'. We likely want that most (all?) of the time.
;; 'week-based-year' can report the wrong year on dates near the start/end of a year based on how
;; ISO-8601 defines what a week is: some days may end up in the 52nd or 1st week of the wrong year:
;; https://stackoverflow.com/a/46395342 provides an explanation.
(str/replace #"YYYY" "yyyy")))) |
The dispatch function logic for format format-timestring. Find the first of the unit or highest type of the object. | (def ^:private col-type (some-fn :unit :semantic_type :effective_type :base_type)) |
Reformat a temporal literal string to the desired format based on column The type is the highest present of semantic, effective, or base type. This is currently expected to be one of:
If neither a unit nor a temporal type is provided, just bottom out by assuming a date. | (defmulti format-timestring
{:arglists '([timezone-id temporal-str col viz-settings])}
(fn [_timezone-id _temporal-str col _viz-settings]
(col-type col))) |
(defmethod format-timestring :minute [timezone-id temporal-str _col {:keys [date-style time-style] :as viz-settings}]
(reformat-temporal-str timezone-id temporal-str
(-> (or date-style "MMMM d, yyyy")
(str ", " (fix-time-style time-style constants/default-time-style))
(post-process-date-style viz-settings)))) | |
(defmethod format-timestring :hour [timezone-id temporal-str _col {:keys [date-style time-style] :as viz-settings}]
(reformat-temporal-str timezone-id temporal-str
(-> (or date-style "MMMM d, yyyy")
(str ", " (fix-time-style time-style "h a"))
(post-process-date-style viz-settings)))) | |
(defmethod format-timestring :day [timezone-id temporal-str _col {:keys [date-style] :as viz-settings}]
(reformat-temporal-str timezone-id temporal-str
(-> (or date-style "EEEE, MMMM d, YYYY")
(post-process-date-style viz-settings)))) | |
(defmethod format-timestring :week [timezone-id temporal-str _col {:keys [date-style] :as viz-settings}]
(let [date-style (or date-style "MMMM d, YYYY")
end-temporal-str (-> temporal-str
u.date/parse
(u.date/add :day 6)
u.date/format)]
(str
(reformat-temporal-str timezone-id temporal-str (post-process-date-style date-style viz-settings))
" - "
(reformat-temporal-str timezone-id end-temporal-str (post-process-date-style date-style viz-settings))))) | |
(defmethod format-timestring :month [timezone-id temporal-str _col {:keys [date-style] :as viz-settings}]
(reformat-temporal-str timezone-id temporal-str
(-> (or date-style "MMMM, yyyy")
(post-process-date-style viz-settings)))) | |
(defmethod format-timestring :quarter [timezone-id temporal-str _col _viz-settings] (reformat-temporal-str timezone-id temporal-str "QQQ - yyyy")) | |
(defmethod format-timestring :year [timezone-id temporal-str _col _viz-settings] (reformat-temporal-str timezone-id temporal-str "YYYY")) | |
(defmethod format-timestring :day-of-week [_timezone-id temporal-str _col {:keys [date-abbreviate]}]
(day-of-week (parse-long temporal-str) date-abbreviate)) | |
(defmethod format-timestring :month-of-year [_timezone-id temporal-str _col {:keys [date-abbreviate]}]
(month-of-year (parse-long temporal-str) date-abbreviate)) | |
(defmethod format-timestring :quarter-of-year [_timezone-id temporal-str _col _viz-settings] (format "Q%s" temporal-str)) | |
(defmethod format-timestring :hour-of-day [_timezone-id temporal-str _col {:keys [time-style]}]
(hour-of-day temporal-str (fix-time-style time-style "h a"))) | |
(defmethod format-timestring :week-of-year [_timezone-id temporal-str _col _viz-settings] (x-of-y (parse-long temporal-str))) | |
(defmethod format-timestring :minute-of-hour [_timezone-id temporal-str _col _viz-settings] (x-of-y (parse-long temporal-str))) | |
(defmethod format-timestring :day-of-month [_timezone-id temporal-str _col _viz-settings] (x-of-y (parse-long temporal-str))) | |
(defmethod format-timestring :day-of-year [_timezone-id temporal-str _col _viz-settings] (x-of-y (parse-long temporal-str))) | |
(defmethod format-timestring :type/Time [timezone-id temporal-str _col viz-settings]
(let [time-style (some-> (determine-time-format viz-settings)
(fix-time-style constants/default-time-style))]
;; ATM, the FE can technically say the time style is `nil` via the `:time-enabled` key. While this doesn't really
;; make sense, we should guard against it by returning an empty string if the time style is `nil`.
(if time-style
(reformat-temporal-str timezone-id temporal-str time-style)
""))) | |
(defmethod format-timestring :type/Date [timezone-id temporal-str _col {:keys [date-style] :as viz-settings}]
(let [date-format (post-process-date-style (or date-style "MMMM d, yyyy") viz-settings)]
(reformat-temporal-str timezone-id temporal-str date-format))) | |
(defmethod format-timestring :type/DateTime [timezone-id temporal-str _col {:keys [date-style] :as viz-settings}]
(let [date-style (or date-style "MMMM d, yyyy")
time-style (some-> (determine-time-format viz-settings)
(fix-time-style constants/default-time-style))
date-time-style (cond-> date-style
time-style
(str ", " time-style))
default-format-string (post-process-date-style date-time-style viz-settings)]
(t/format default-format-string (u.date/parse temporal-str timezone-id)))) | |
(defmethod format-timestring :default [timezone-id temporal-str {:keys [unit] :as col} {:keys [date-style] :as viz-settings}]
(if (= :default unit)
;; When the unit is the `:default` literal we want to retry formatting with the data types contained in col.
(format-timestring timezone-id temporal-str (dissoc col :unit) viz-settings)
;; We're making an assumption when we bottom out here that the string is compatible with this default format,
;; 'MMMM d, yyyy'. If the time string isn't compatible with this format, we just return the string.
;; This is not likely to happen IRL since you generally have a useful unit or know the type of the colum. A failure
;; mode that can be reproduced in test is trying to format a time string (e.g.'15:30:45Z') when the column has no
;; type information (e.g. a semantic or effective type of `:type/Time`).
(let [date-format (post-process-date-style (or date-style "MMMM d, yyyy") viz-settings)]
(try
(reformat-temporal-str timezone-id temporal-str date-format)
(catch Exception _
(log/warnf "Could not format temporal string %s in time zone %s with format %s."
temporal-str
timezone-id
date-format)
temporal-str))))) | |
Return a formatter which, given a temporal literal string, reformts it by combining time zone, column, and viz setting information to create a final desired output format. | (defn make-temporal-str-formatter
[timezone-id col viz-settings]
(Locale/setDefault (Locale. (public-settings/site-locale)))
(let [merged-viz-settings (common/normalize-keys
(common/viz-settings-for-col col viz-settings))]
(fn [temporal-str]
(if (str/blank? temporal-str)
""
(format-timestring timezone-id temporal-str col merged-viz-settings))))) |