diff --git a/src/jolt/time/fmt.clj b/src/jolt/time/fmt.clj index f685251..b8530c8 100644 --- a/src/jolt/time/fmt.clj +++ b/src/jolt/time/fmt.clj @@ -1,9 +1,13 @@ (ns jolt.time.fmt "DateTimeFormatter + DateTimeFormatterBuilder. A pattern engine over the library - temporals; month/day display names are English by default, or locale-specific - through the core jolt.host/locale-name (strftime) primitive." - (:require [jolt.host :as host] + temporals; localized patterns and month/day display names come from the + bundled jolt.time.locale-data tables (measured from the reference JVM), with + the core jolt.host/locale-name (strftime) primitive as a last resort." + (:require [clojure.string :as str] + [jolt.host :as host] [jolt.time.impl :as impl :refer [civil-from-days days-from-civil]] + [jolt.time.locale-data :as ld] + [jolt.time.number-data :as nd] [jolt.time.util :as u] [jolt.time.local :as l] [jolt.time.year :as y] @@ -19,13 +23,15 @@ (def ^:private en-days ["Monday" "Tuesday" "Wednesday" "Thursday" "Friday" "Saturday" "Sunday"]) (defn- month-name [locale mo full?] - (if (or (nil? locale) (= "en" locale)) - (let [n (nth en-months (dec mo))] (if full? n (subs n 0 3))) + ;; bundled tables first: they are right everywhere; strftime depends on the OS + ;; having the locale installed and silently falls back to English when it does not + (if-let [names (ld/months locale full?)] + (nth names (dec mo)) (or (host/locale-name locale (dec mo) 1 (if full? "%B" "%b")) (let [n (nth en-months (dec mo))] (if full? n (subs n 0 3)))))) (defn- day-name [locale dow full?] - (if (or (nil? locale) (= "en" locale)) - (let [n (nth en-days (dec dow))] (if full? n (subs n 0 3))) + (if-let [names (ld/days locale full?)] + (nth names (dec dow)) (or (host/locale-name locale 0 (mod dow 7) (if full? "%A" "%a")) (let [n (nth en-days (dec dow))] (if full? n (subs n 0 3)))))) @@ -78,7 +84,9 @@ (let [c (nth pattern i) k (run-len i c)] (cond (= c \') (let [[j o] (scan-quote pattern i n out)] (recur j o)) - (or (= c \y) (= c \Y)) (recur (+ i k) (str out (if (>= k 4) (u/pad4 y) (u/pad2 (mod y 100))))) + ;; java.time year semantics: y is the full year, yy the reduced + ;; two-digit form, yyy+ padded (CLDR localized patterns use bare y) + (or (= c \y) (= c \Y)) (recur (+ i k) (str out (cond (= k 1) (str y) (= k 2) (u/pad2 (mod y 100)) :else (u/pad4 y)))) (= c \M) (recur (+ i k) (str out (cond (= k 1) (str mo) (= k 2) (u/pad2 mo) (= k 3) (month-name locale mo false) :else (month-name locale mo true)))) (= c \d) (recur (+ i k) (str out (if (= k 1) (str d) (u/pad2 d)))) (= c \E) (recur (+ i k) (str out (day-name locale dow (>= k 4)))) @@ -96,7 +104,10 @@ ;; --- DateTimeFormatter ------------------------------------------------------- (defn formatter [pattern locale] (impl/value :jolt.time/dt-formatter {:pattern pattern :locale (or locale "en")})) -(defn- fmt-pattern [f] (impl/field f :pattern)) +(defn- fmt-pattern [f] + (or (impl/field f :pattern) + (let [[kind style] (impl/field f :style)] + (or (ld/pattern (impl/field f :locale) kind style) "yyyy-MM-dd HH:mm:ss")))) (defn- fmt-locale [f] (impl/field f :locale)) (defn fmt? [f] (= :jolt.time/dt-formatter (impl/type-of f))) @@ -118,7 +129,10 @@ (__register-class-methods! :jolt.time/dt-formatter {"format" (fn [self v] (format-pattern (fmt-pattern self) v (fmt-locale self))) - "withLocale" (fn [self l] (formatter (fmt-pattern self) (locale-id l))) + "withLocale" (fn [self l] (impl/value :jolt.time/dt-formatter + {:pattern (impl/field self :pattern) + :style (impl/field self :style) + :locale (locale-id l)})) "withZone" (fn [self _z] self) "parse" (fn [self s] (let [f (parse-with-pattern (fmt-pattern self) (str s))] (l/local-dt (days-from-civil (:year f) (:month f) (:day f)) @@ -149,23 +163,72 @@ "SIMPLIFIED_CHINESE" (impl/value :jolt.time/locale {:id "zh-CN"}) "TRADITIONAL_CHINESE" (impl/value :jolt.time/locale {:id "zh-TW"}) "UK" (impl/value :jolt.time/locale {:id "en-GB"}) "CANADA" (impl/value :jolt.time/locale {:id "en-CA"}) "CANADA_FRENCH" (impl/value :jolt.time/locale {:id "fr-CA"}) "ROOT" (impl/value :jolt.time/locale {:id ""})}) -(__register-class-ctor! "Locale" (fn [lang & _] (impl/value :jolt.time/locale {:id (str lang)}))) -(__register-class-ctor! "java.util.Locale" (fn [lang & _] (impl/value :jolt.time/locale {:id (str lang)}))) +;; the two-arg ctor joins language and country like the constants do ("de" "DE" +;; -> "de-DE"); the one-arg ctor's argument is a *language* subtag, so "en_US" +;; stays malformed and resolves to ROOT at lookup time +(defn- locale-ctor [lang & r] + (let [lang (str/lower-case (str lang))] + (if (and (seq r) (seq (str (first r)))) + (impl/value :jolt.time/locale {:id (str lang "-" (str/upper-case (str (first r))))}) + (impl/value :jolt.time/locale {:id lang})))) +(__register-class-ctor! "Locale" locale-ctor) +(__register-class-ctor! "java.util.Locale" locale-ctor) (impl/register-type! :jolt.time/locale {:eq (fn [a b] (= (impl/field a :id) (impl/field b :id))) :hash (fn [l] (hash (impl/field l :id))) :str (fn [l] (impl/field l :id)) :cmp nil :classes #{"java.util.Locale" "Locale"}}) -(def ^:private style-patterns - {:date {"SHORT" "M/d/yy" "MEDIUM" "MMM d, yyyy" "LONG" "MMMM d, yyyy" "FULL" "EEEE, MMMM d, yyyy"} - :time {"SHORT" "h:mm a" "MEDIUM" "h:mm:ss a" "LONG" "h:mm:ss a" "FULL" "h:mm:ss a"} - :datetime {"SHORT" "M/d/yy, h:mm a" "MEDIUM" "MMM d, yyyy, h:mm:ss a" "LONG" "MMMM d, yyyy, h:mm:ss a" "FULL" "EEEE, MMMM d, yyyy, h:mm:ss a"}}) +;; jolt core's java.text.SimpleDateFormat renders MMM/MMMM/EEE/EEEE through a +;; :date-names extension point and carries ROOT alone, because core has no locale +;; data of its own. Hand it the bundled tables so an explicit locale renders in its +;; own language there too, not only through this library's DateTimeFormatter. +;; +;; Guarded on the point existing, so a jolt older than it still loads this library +;; and simply keeps ROOT names in SimpleDateFormat. The guard is deliberately +;; narrow: it swallows the one "not declared" case and rethrows anything else, so a +;; genuine error in a provider still surfaces. +;; The same applies to the other two locale-sensitive surfaces core declares: +;; :number-symbols (String/format's decimal and grouping separators) and +;; :currency-data (NumberFormat/getCurrencyInstance). All three are fed from the +;; bundled tables here, because core carries ROOT alone for each. +;; +;; A point core does not declare is skipped rather than failing the load, so this +;; library keeps working against a jolt that has only some of them. +(defn- register-point! [reg point entries] + (try + (doseq [[id data] entries :when (not= id "")] + (reg point id data)) + true + (catch Exception e + (if (re-find #"no extension point" (or (ex-message e) "")) + false + (throw e))))) + +(defn- register-locale-points! [] + (when-let [reg (resolve 'jolt.host/register-extension!)] + (register-point! reg :date-names + (for [[id spec] ld/locales] + [id {:months (:months spec) :months-short (:months-short spec) + :days (:days spec) :days-short (:days-short spec)}])) + (register-point! reg :number-symbols nd/number-symbols) + (register-point! reg :currency-data nd/currency) + true)) + +;; register-point! already tolerates an undeclared point per point, so this only +;; has to run once. +(defonce ^:private locale-points-registered (boolean (register-locale-points!))) + +(def ^:private style-key {"SHORT" :short "MEDIUM" :medium "LONG" :long "FULL" :full}) (statics! ["FormatStyle" "java.time.format.FormatStyle"] (into {} (map (fn [s] [s (impl/value :jolt.time/format-style {:style s})]) ["SHORT" "MEDIUM" "LONG" "FULL"]))) (impl/register-type! :jolt.time/format-style {:eq (fn [a b] (= (impl/field a :style) (impl/field b :style))) :hash (fn [s] (hash (impl/field s :style))) :str (fn [s] (impl/field s :style)) :cmp nil :classes #{"java.time.format.FormatStyle" "FormatStyle"}}) (defn- style-of [fs] (impl/field fs :style)) -(defn- style-fmt [kind fs] (formatter (get-in style-patterns [kind (style-of fs)] "yyyy-MM-dd HH:mm:ss") "en")) +;; ofLocalized* defers pattern resolution: the pattern depends on the locale, +;; which .withLocale supplies after construction, so the value carries +;; [kind style] and fmt-pattern resolves it per locale at use time. +(defn- style-fmt [kind fs] + (impl/value :jolt.time/dt-formatter {:style [kind (style-key (style-of fs))] :locale "en"})) (statics! ["DateTimeFormatter" "java.time.format.DateTimeFormatter"] {"ofPattern" (fn [p & r] (formatter (str p) (if (seq r) (locale-id (first r)) "en"))) @@ -184,7 +247,7 @@ "RFC_1123_DATE_TIME" (formatter "EEE, dd MMM yyyy HH:mm:ss Z" "en") "ofLocalizedDate" (fn [fs] (style-fmt :date fs)) "ofLocalizedTime" (fn [fs] (style-fmt :time fs)) - "ofLocalizedDateTime" (fn [fs] (style-fmt :datetime fs))}) + "ofLocalizedDateTime" (fn [fs] (style-fmt :date-time fs))}) ;; --- DateTimeFormatterBuilder (minimal) -------------------------------------- (defn- builder [] (impl/value :jolt.time/dtf-builder {:pattern (atom "")})) diff --git a/src/jolt/time/locale_data.clj b/src/jolt/time/locale_data.clj new file mode 100644 index 0000000..bfdc883 --- /dev/null +++ b/src/jolt/time/locale_data.clj @@ -0,0 +1,627 @@ +(ns jolt.time.locale-data + "Localized date/time format data measured from the reference JVM's CLDR, plus + the lookups over it. Per locale id: :patterns keyed by [kind style] with kind + :date/:time/:date-time and style :short/:medium/:long/:full, and :months, + :months-short, :days, :days-short name tables (months January-first, days + Monday-first). Ids are BCP-47-ish \"lang-COUNTRY\"; \"\" is ROOT. Every value is + as the reference JVM renders it, including ROOT's wide month/day names, which + CLDR abbreviates ("Mar", "Sat") rather than spelling out.") + +(def locales + {"" + {:patterns + {[:date-time :full] "y MMMM d, EEEE HH:mm:ss zzzz", + [:date-time :long] "y MMMM d HH:mm:ss z", + [:date :full] "y MMMM d, EEEE", + [:time :medium] "HH:mm:ss", + [:date :short] "y-MM-dd", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "y MMM d", + [:date :long] "y MMMM d", + [:date-time :short] "y-MM-dd HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "y MMM d HH:mm:ss"}, + :months + ["Jan" + "Feb" + "Mar" + "Apr" + "May" + "Jun" + "Jul" + "Aug" + "Sep" + "Oct" + "Nov" + "Dec"], + :months-short + ["Jan" + "Feb" + "Mar" + "Apr" + "May" + "Jun" + "Jul" + "Aug" + "Sep" + "Oct" + "Nov" + "Dec"], + :days ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"], + :days-short ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]}, + "nl" + {:patterns + {[:date-time :full] "EEEE d MMMM y, HH:mm:ss zzzz", + [:date-time :long] "d MMMM y, HH:mm:ss z", + [:date :full] "EEEE d MMMM y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd-MM-y", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d MMM y", + [:date :long] "d MMMM y", + [:date-time :short] "dd-MM-y, HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d MMM y, HH:mm:ss"}, + :months + ["januari" + "februari" + "maart" + "april" + "mei" + "juni" + "juli" + "augustus" + "september" + "oktober" + "november" + "december"], + :months-short + ["jan" + "feb" + "mrt" + "apr" + "mei" + "jun" + "jul" + "aug" + "sep" + "okt" + "nov" + "dec"], + :days + ["maandag" + "dinsdag" + "woensdag" + "donderdag" + "vrijdag" + "zaterdag" + "zondag"], + :days-short ["ma" "di" "wo" "do" "vr" "za" "zo"]}, + "pt" + {:patterns + {[:date-time :full] "EEEE, d 'de' MMMM 'de' y HH:mm:ss zzzz", + [:date-time :long] "d 'de' MMMM 'de' y HH:mm:ss z", + [:date :full] "EEEE, d 'de' MMMM 'de' y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd/MM/y", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d 'de' MMM 'de' y", + [:date :long] "d 'de' MMMM 'de' y", + [:date-time :short] "dd/MM/y HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d 'de' MMM 'de' y HH:mm:ss"}, + :months + ["janeiro" + "fevereiro" + "março" + "abril" + "maio" + "junho" + "julho" + "agosto" + "setembro" + "outubro" + "novembro" + "dezembro"], + :months-short + ["jan." + "fev." + "mar." + "abr." + "mai." + "jun." + "jul." + "ago." + "set." + "out." + "nov." + "dez."], + :days + ["segunda-feira" + "terça-feira" + "quarta-feira" + "quinta-feira" + "sexta-feira" + "sábado" + "domingo"], + :days-short ["seg." "ter." "qua." "qui." "sex." "sáb." "dom."]}, + "en" + {:patterns + {[:date-time :full] "EEEE, MMMM d, y, h:mm:ss a zzzz", + [:date-time :long] "MMMM d, y, h:mm:ss a z", + [:date :full] "EEEE, MMMM d, y", + [:time :medium] "h:mm:ss a", + [:date :short] "M/d/yy", + [:time :full] "h:mm:ss a zzzz", + [:time :long] "h:mm:ss a z", + [:date :medium] "MMM d, y", + [:date :long] "MMMM d, y", + [:date-time :short] "M/d/yy, h:mm a", + [:time :short] "h:mm a", + [:date-time :medium] "MMM d, y, h:mm:ss a"}, + :months + ["January" + "February" + "March" + "April" + "May" + "June" + "July" + "August" + "September" + "October" + "November" + "December"], + :months-short + ["Jan" + "Feb" + "Mar" + "Apr" + "May" + "Jun" + "Jul" + "Aug" + "Sep" + "Oct" + "Nov" + "Dec"], + :days + ["Monday" + "Tuesday" + "Wednesday" + "Thursday" + "Friday" + "Saturday" + "Sunday"], + :days-short ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]}, + "zh" + {:patterns + {[:date-time :full] "y年M月d日EEEE zzzz HH:mm:ss", + [:date-time :long] "y年M月d日 z HH:mm:ss", + [:date :full] "y年M月d日EEEE", + [:time :medium] "HH:mm:ss", + [:date :short] "y/M/d", + [:time :full] "zzzz HH:mm:ss", + [:time :long] "z HH:mm:ss", + [:date :medium] "y年M月d日", + [:date :long] "y年M月d日", + [:date-time :short] "y/M/d HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "y年M月d日 HH:mm:ss"}, + :months + ["一月" "二月" "三月" "四月" "五月" "六月" "七月" "八月" "九月" "十月" "十一月" "十二月"], + :months-short + ["1月" "2月" "3月" "4月" "5月" "6月" "7月" "8月" "9月" "10月" "11月" "12月"], + :days ["星期一" "星期二" "星期三" "星期四" "星期五" "星期六" "星期日"], + :days-short ["周一" "周二" "周三" "周四" "周五" "周六" "周日"]}, + "it" + {:patterns + {[:date-time :full] "EEEE d MMMM y HH:mm:ss zzzz", + [:date-time :long] "d MMMM y HH:mm:ss z", + [:date :full] "EEEE d MMMM y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd/MM/yy", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d MMM y", + [:date :long] "d MMMM y", + [:date-time :short] "dd/MM/yy, HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d MMM y, HH:mm:ss"}, + :months + ["gennaio" + "febbraio" + "marzo" + "aprile" + "maggio" + "giugno" + "luglio" + "agosto" + "settembre" + "ottobre" + "novembre" + "dicembre"], + :months-short + ["gen" + "feb" + "mar" + "apr" + "mag" + "giu" + "lug" + "ago" + "set" + "ott" + "nov" + "dic"], + :days + ["lunedì" + "martedì" + "mercoledì" + "giovedì" + "venerdì" + "sabato" + "domenica"], + :days-short ["lun" "mar" "mer" "gio" "ven" "sab" "dom"]}, + "fr" + {:patterns + {[:date-time :full] "EEEE d MMMM y, HH:mm:ss zzzz", + [:date-time :long] "d MMMM y, HH:mm:ss z", + [:date :full] "EEEE d MMMM y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd/MM/y", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d MMM y", + [:date :long] "d MMMM y", + [:date-time :short] "dd/MM/y HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d MMM y, HH:mm:ss"}, + :months + ["janvier" + "février" + "mars" + "avril" + "mai" + "juin" + "juillet" + "août" + "septembre" + "octobre" + "novembre" + "décembre"], + :months-short + ["janv." + "févr." + "mars" + "avr." + "mai" + "juin" + "juil." + "août" + "sept." + "oct." + "nov." + "déc."], + :days + ["lundi" "mardi" "mercredi" "jeudi" "vendredi" "samedi" "dimanche"], + :days-short ["lun." "mar." "mer." "jeu." "ven." "sam." "dim."]}, + "de" + {:patterns + {[:date-time :full] "EEEE, d. MMMM y, HH:mm:ss zzzz", + [:date-time :long] "d. MMMM y, HH:mm:ss z", + [:date :full] "EEEE, d. MMMM y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd.MM.yy", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "dd.MM.y", + [:date :long] "d. MMMM y", + [:date-time :short] "dd.MM.yy, HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "dd.MM.y, HH:mm:ss"}, + :months + ["Januar" + "Februar" + "März" + "April" + "Mai" + "Juni" + "Juli" + "August" + "September" + "Oktober" + "November" + "Dezember"], + :months-short + ["Jan." + "Feb." + "März" + "Apr." + "Mai" + "Juni" + "Juli" + "Aug." + "Sept." + "Okt." + "Nov." + "Dez."], + :days + ["Montag" + "Dienstag" + "Mittwoch" + "Donnerstag" + "Freitag" + "Samstag" + "Sonntag"], + :days-short ["Mo." "Di." "Mi." "Do." "Fr." "Sa." "So."]}, + "ru" + {:patterns + {[:date-time :full] "EEEE, d MMMM y 'г'., HH:mm:ss zzzz", + [:date-time :long] "d MMMM y 'г'., HH:mm:ss z", + [:date :full] "EEEE, d MMMM y 'г'.", + [:time :medium] "HH:mm:ss", + [:date :short] "dd.MM.y", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d MMM y 'г'.", + [:date :long] "d MMMM y 'г'.", + [:date-time :short] "dd.MM.y, HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d MMM y 'г'., HH:mm:ss"}, + :months + ["января" + "февраля" + "марта" + "апреля" + "мая" + "июня" + "июля" + "августа" + "сентября" + "октября" + "ноября" + "декабря"], + :months-short + ["янв." + "февр." + "мар." + "апр." + "мая" + "июн." + "июл." + "авг." + "сент." + "окт." + "нояб." + "дек."], + :days + ["понедельник" + "вторник" + "среда" + "четверг" + "пятница" + "суббота" + "воскресенье"], + :days-short ["пн" "вт" "ср" "чт" "пт" "сб" "вс"]}, + "es" + {:patterns + {[:date-time :full] "EEEE, d 'de' MMMM 'de' y, H:mm:ss (zzzz)", + [:date-time :long] "d 'de' MMMM 'de' y, H:mm:ss z", + [:date :full] "EEEE, d 'de' MMMM 'de' y", + [:time :medium] "H:mm:ss", + [:date :short] "d/M/yy", + [:time :full] "H:mm:ss (zzzz)", + [:time :long] "H:mm:ss z", + [:date :medium] "d MMM y", + [:date :long] "d 'de' MMMM 'de' y", + [:date-time :short] "d/M/yy, H:mm", + [:time :short] "H:mm", + [:date-time :medium] "d MMM y, H:mm:ss"}, + :months + ["enero" + "febrero" + "marzo" + "abril" + "mayo" + "junio" + "julio" + "agosto" + "septiembre" + "octubre" + "noviembre" + "diciembre"], + :months-short + ["ene" + "feb" + "mar" + "abr" + "may" + "jun" + "jul" + "ago" + "sept" + "oct" + "nov" + "dic"], + :days + ["lunes" "martes" "miércoles" "jueves" "viernes" "sábado" "domingo"], + :days-short ["lun" "mar" "mié" "jue" "vie" "sáb" "dom"]}, + "en-US" + {:patterns + {[:date-time :full] "EEEE, MMMM d, y, h:mm:ss a zzzz", + [:date-time :long] "MMMM d, y, h:mm:ss a z", + [:date :full] "EEEE, MMMM d, y", + [:time :medium] "h:mm:ss a", + [:date :short] "M/d/yy", + [:time :full] "h:mm:ss a zzzz", + [:time :long] "h:mm:ss a z", + [:date :medium] "MMM d, y", + [:date :long] "MMMM d, y", + [:date-time :short] "M/d/yy, h:mm a", + [:time :short] "h:mm a", + [:date-time :medium] "MMM d, y, h:mm:ss a"}, + :months + ["January" + "February" + "March" + "April" + "May" + "June" + "July" + "August" + "September" + "October" + "November" + "December"], + :months-short + ["Jan" + "Feb" + "Mar" + "Apr" + "May" + "Jun" + "Jul" + "Aug" + "Sep" + "Oct" + "Nov" + "Dec"], + :days + ["Monday" + "Tuesday" + "Wednesday" + "Thursday" + "Friday" + "Saturday" + "Sunday"], + :days-short ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]}, + "ja" + {:patterns + {[:date-time :full] "y年M月d日EEEE H時mm分ss秒 zzzz", + [:date-time :long] "y年M月d日 H:mm:ss z", + [:date :full] "y年M月d日EEEE", + [:time :medium] "H:mm:ss", + [:date :short] "y/MM/dd", + [:time :full] "H時mm分ss秒 zzzz", + [:time :long] "H:mm:ss z", + [:date :medium] "y/MM/dd", + [:date :long] "y年M月d日", + [:date-time :short] "y/MM/dd H:mm", + [:time :short] "H:mm", + [:date-time :medium] "y/MM/dd H:mm:ss"}, + :months + ["1月" "2月" "3月" "4月" "5月" "6月" "7月" "8月" "9月" "10月" "11月" "12月"], + :months-short + ["1月" "2月" "3月" "4月" "5月" "6月" "7月" "8月" "9月" "10月" "11月" "12月"], + :days ["月曜日" "火曜日" "水曜日" "木曜日" "金曜日" "土曜日" "日曜日"], + :days-short ["月" "火" "水" "木" "金" "土" "日"]}, + "en-GB" + {:patterns + {[:date-time :full] "EEEE, d MMMM y, HH:mm:ss zzzz", + [:date-time :long] "d MMMM y, HH:mm:ss z", + [:date :full] "EEEE, d MMMM y", + [:time :medium] "HH:mm:ss", + [:date :short] "dd/MM/y", + [:time :full] "HH:mm:ss zzzz", + [:time :long] "HH:mm:ss z", + [:date :medium] "d MMM y", + [:date :long] "d MMMM y", + [:date-time :short] "dd/MM/y, HH:mm", + [:time :short] "HH:mm", + [:date-time :medium] "d MMM y, HH:mm:ss"}, + :months + ["January" + "February" + "March" + "April" + "May" + "June" + "July" + "August" + "September" + "October" + "November" + "December"], + :months-short + ["Jan" + "Feb" + "Mar" + "Apr" + "May" + "Jun" + "Jul" + "Aug" + "Sept" + "Oct" + "Nov" + "Dec"], + :days + ["Monday" + "Tuesday" + "Wednesday" + "Thursday" + "Friday" + "Saturday" + "Sunday"], + :days-short ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]}, + "ko" + {:patterns + {[:date-time :full] "y년 MMMM d일 EEEE a h시 m분 s초 zzzz", + [:date-time :long] "y년 MMMM d일 a h시 m분 s초 z", + [:date :full] "y년 MMMM d일 EEEE", + [:time :medium] "a h:mm:ss", + [:date :short] "yy. M. d.", + [:time :full] "a h시 m분 s초 zzzz", + [:time :long] "a h시 m분 s초 z", + [:date :medium] "y. M. d.", + [:date :long] "y년 MMMM d일", + [:date-time :short] "yy. M. d. a h:mm", + [:time :short] "a h:mm", + [:date-time :medium] "y. M. d. a h:mm:ss"}, + :months + ["1월" "2월" "3월" "4월" "5월" "6월" "7월" "8월" "9월" "10월" "11월" "12월"], + :months-short + ["1월" "2월" "3월" "4월" "5월" "6월" "7월" "8월" "9월" "10월" "11월" "12월"], + :days ["월요일" "화요일" "수요일" "목요일" "금요일" "토요일" "일요일"], + :days-short ["월" "화" "수" "목" "금" "토" "일"]}} +) + +(defn- last-hyphen [s] + (loop [i (dec (count s))] + (cond (neg? i) nil + (= \- (nth s i)) i + :else (recur (dec i))))) + +(defn- has-underscore? [s] + (loop [i 0] + (cond (>= i (count s)) false + (= \_ (nth s i)) true + :else (recur (inc i))))) + +(defn resolve-id + "Narrow a locale id to the most specific id the tables carry: \"zh-CN\" -> + \"zh\" -> \"\". An id containing an underscore is a malformed language subtag + (e.g. (Locale. \"en_US\")) and lands on ROOT, matching the JVM." + [id] + (let [id (or id "")] + (cond + (contains? locales id) id + (has-underscore? id) "" + :else (loop [s id] + (if-let [i (last-hyphen s)] + (let [p (subs s 0 i)] + (if (contains? locales p) p (recur p))) + ""))))) + +(defn pattern + "Localized pattern for [kind style]; kind :date/:time/:date-time, style + :short/:medium/:long/:full. Always non-nil: ROOT carries all twelve." + [id kind style] + (get-in locales [(resolve-id id) :patterns [kind style]])) + +(defn months [id full?] (get-in locales [(resolve-id id) (if full? :months :months-short)])) +(defn days [id full?] (get-in locales [(resolve-id id) (if full? :days :days-short)])) diff --git a/src/jolt/time/number_data.clj b/src/jolt/time/number_data.clj new file mode 100644 index 0000000..2ef2521 --- /dev/null +++ b/src/jolt/time/number_data.clj @@ -0,0 +1,76 @@ +(ns jolt.time.number-data + "Per-locale number and currency symbols, measured from the reference JVM. + + Feeds jolt core's :number-symbols and :currency-data extension points, which + core declares with ROOT values only because it carries no locale data of its + own. Ids are BCP-47-ish lang-COUNTRY; the empty id is ROOT. + + Note a language-only id has no country and therefore no currency: the JVM + renders de as the generic currency sign, and only a country id such as + de-DE carries a real symbol. Both are here, as measured -- every currency + entry was checked by rebuilding the JVM's formatted output from these fields." + (:require [jolt.time.locale-data :as ld])) + +(def number-symbols + {"" {:decimal-sep "." :grouping-sep ","} + "de" {:decimal-sep "," :grouping-sep "."} + "de-CH" {:decimal-sep "." :grouping-sep "'"} + "de-DE" {:decimal-sep "," :grouping-sep "."} + "en" {:decimal-sep "." :grouping-sep ","} + "en-GB" {:decimal-sep "." :grouping-sep ","} + "en-US" {:decimal-sep "." :grouping-sep ","} + "es" {:decimal-sep "," :grouping-sep "."} + "es-ES" {:decimal-sep "," :grouping-sep "."} + "fr" {:decimal-sep "," :grouping-sep " "} + "fr-FR" {:decimal-sep "," :grouping-sep " "} + "it" {:decimal-sep "," :grouping-sep "."} + "it-IT" {:decimal-sep "," :grouping-sep "."} + "ja" {:decimal-sep "." :grouping-sep ","} + "ja-JP" {:decimal-sep "." :grouping-sep ","} + "ko" {:decimal-sep "." :grouping-sep ","} + "ko-KR" {:decimal-sep "." :grouping-sep ","} + "nl" {:decimal-sep "," :grouping-sep "."} + "nl-NL" {:decimal-sep "," :grouping-sep "."} + "pt" {:decimal-sep "," :grouping-sep "."} + "pt-BR" {:decimal-sep "," :grouping-sep "."} + "ru" {:decimal-sep "," :grouping-sep " "} + "ru-RU" {:decimal-sep "," :grouping-sep " "} + "zh" {:decimal-sep "." :grouping-sep ","} + "zh-CN" {:decimal-sep "." :grouping-sep ","}}) + +(def currency + {"" {:symbol "¤", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "de" {:symbol "¤", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "de-CH" {:symbol "CHF", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep "'"} + "de-DE" {:symbol "€", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "en" {:symbol "¤", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "en-GB" {:symbol "£", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "en-US" {:symbol "$", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "es" {:symbol "¤", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "es-ES" {:symbol "€", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "fr" {:symbol "¤", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep " "} + "fr-FR" {:symbol "€", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep " "} + "it" {:symbol "¤", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "it-IT" {:symbol "€", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "ja" {:symbol "¤", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "ja-JP" {:symbol "¥", :symbol-sep "", :symbol-first? true, :frac-digits 0, :decimal-sep ".", :grouping-sep ","} + "ko" {:symbol "¤", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "ko-KR" {:symbol "₩", :symbol-sep "", :symbol-first? true, :frac-digits 0, :decimal-sep ".", :grouping-sep ","} + "nl" {:symbol "¤", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "nl-NL" {:symbol "€", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "pt" {:symbol "¤", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "pt-BR" {:symbol "R$", :symbol-sep " ", :symbol-first? true, :frac-digits 2, :decimal-sep ",", :grouping-sep "."} + "ru" {:symbol "¤", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep " "} + "ru-RU" {:symbol "₽", :symbol-sep " ", :symbol-first? false, :frac-digits 2, :decimal-sep ",", :grouping-sep " "} + "zh" {:symbol "¤", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","} + "zh-CN" {:symbol "¥", :symbol-sep "", :symbol-first? true, :frac-digits 2, :decimal-sep ".", :grouping-sep ","}}) + +(defn symbols-for + "Number symbols for a locale id, narrowed the same way the pattern tables are." + [id] + (get number-symbols (ld/resolve-id id) (get number-symbols ""))) + +(defn currency-for + "Currency data for a locale id, narrowed the same way." + [id] + (get currency (ld/resolve-id id) (get currency ""))) diff --git a/test/jolt/time/fmt_test.clj b/test/jolt/time/fmt_test.clj index 2c66301..edcada5 100644 --- a/test/jolt/time/fmt_test.clj +++ b/test/jolt/time/fmt_test.clj @@ -25,5 +25,43 @@ (is (= "Mar 5, 2020" (.format (DateTimeFormatter/ofLocalizedDate FormatStyle/MEDIUM) (LocalDate/of 2020 3 5)))) (is (string? (.format (.withLocale (DateTimeFormatter/ofPattern "yyyy") (java.util.Locale. "en")) (LocalDate/of 2020 1 1))))) +(deftest localized-per-locale + ;; Selmer's locale date assertions, as produced by the reference JVM; the date + ;; is 2014-03-01T00:00. Note (java.util.Locale. "en_US") is a malformed + ;; *language* subtag and resolves to ROOT (ISO-ish patterns), not to English + ;; and not to US. + (let [d (LocalDateTime/of 2014 3 1 0 0 0) + fmt (fn [f id] (.format (.withLocale f (java.util.Locale. id)) d))] + ;; month name from the bundled table; no OS locale needed + (is (= "mars" (fmt (DateTimeFormatter/ofPattern "MMMM") "fr"))) + (is (= "00:00" (fmt (DateTimeFormatter/ofLocalizedTime FormatStyle/SHORT) "en_US"))) + (is (= "00:00" (fmt (DateTimeFormatter/ofLocalizedTime FormatStyle/SHORT) "zh"))) + (is (= "2014-03-01" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/SHORT) "en_US"))) + (is (= "2014/3/1" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/SHORT) "zh"))) + (is (= "2014-03-01 00:00" (fmt (DateTimeFormatter/ofLocalizedDateTime FormatStyle/SHORT) "en_US"))) + (is (= "2014/3/1 00:00" (fmt (DateTimeFormatter/ofLocalizedDateTime FormatStyle/SHORT) "zh"))) + (is (= "2014年3月1日 00:00:00" (fmt (DateTimeFormatter/ofLocalizedDateTime FormatStyle/MEDIUM) "zh"))) + (is (= "2014年3月1日" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/LONG) "zh"))) + ;; ROOT wide month is "Mar", not "March" — CLDR ROOT abbreviates, verified on the + ;; reference JVM. Selmer accepts either shape here and says so in its own comment. + (is (= "2014 Mar 1" (fmt (DateTimeFormatter/ofLocalizedDate FormatStyle/LONG) "en_US"))) + ;; day name from the bundled table; 2014-03-01 is a Saturday + (is (= "星期六" (fmt (DateTimeFormatter/ofPattern "EEEE") "zh"))))) + +(deftest localized-resolution + (let [d (LocalDate/of 2014 3 1) + short-date (fn [locale] (.format (.withLocale (DateTimeFormatter/ofLocalizedDate FormatStyle/SHORT) locale) d))] + ;; narrowing: zh-CN has no entry of its own and falls back to zh + (is (= "2014年3月1日" + (.format (.withLocale (DateTimeFormatter/ofLocalizedDate FormatStyle/LONG) (java.util.Locale. "zh" "CN")) d))) + ;; a well-formed but unknown id lands on ROOT + (is (= "2014-03-01" (short-date (java.util.Locale. "tlh")))) + (is (= "2014-03-01" (short-date (java.util.Locale. "xx" "YY")))) + ;; US proper is not ROOT + (is (= "3/1/14" (short-date (java.util.Locale. "en" "US")))) + ;; the two-arg ctor joins language and country like the constants do + (is (= "01.03.14" (short-date (java.util.Locale. "de" "DE")))) + (is (= (short-date (java.util.Locale. "de" "DE")) (short-date java.util.Locale/GERMANY))))) + (deftest via-temporal-method (is (= "2020-03-05" (.format (LocalDate/of 2020 3 5) (DateTimeFormatter/ofPattern "yyyy-MM-dd")))))