diff --git a/src/taoensso/tower.clj b/src/taoensso/tower.clj index 3c35d3f..bae08e6 100644 --- a/src/taoensso/tower.clj +++ b/src/taoensso/tower.clj @@ -10,7 +10,17 @@ (:import [java.util Date Locale TimeZone Formatter] [java.text Collator NumberFormat DateFormat])) -;;;; Locales (big L for the Java object) & bindings +;;;; Locales +;;; We use the following terms: +;; 'Locale' - Valid JVM Locale object. +;; 'locale' - Valid JVM Locale object, or a locale kw like `:en-GB`. +;; 'jvm-locale' - Valid JVM Locale object, or a locale kw like `:en-GB` which +;; can become a valid JVM Locale object. +;; 'kw-locale' - A locale kw like `:en-GB`. +;; +;; The localization API wraps JVM facilities so requires locales which are or +;; can become valid JVM Locale objects. In contrast, the translation API is +;; independent of any JVM facilities so can take arbitrary locales. (def ^:private ensure-valid-Locale (set (Locale/getAvailableLocales))) (defn- make-Locale @@ -21,8 +31,8 @@ ([lang country] (Locale. lang country)) ([lang country variant] (Locale. lang country variant))) -(defn try-locale - "Like `locale` but returns nil if no valid matching Locale could be found." +(defn try-jvm-locale + "Like `jvm-locale` but returns nil if no valid matching Locale could be found." [loc & [lang-only?]] (when loc (cond @@ -41,23 +51,32 @@ (apply make-Locale loc-parts) (make-Locale (first loc-parts)))))))) -(def locale +(def jvm-locale "Returns valid Locale matching given name string/keyword, or throws an exception if none could be found. `loc` should be of form :en, :en-US, :en-US-variant, or :jvm-default." (memoize (fn [loc & [lang-only?]] - (or (try-locale loc lang-only?) - (throw (Exception. (format "Invalid locale: %s" (str loc)))))))) - -(def locale-key "Returns locale keyword for given Locale object or locale keyword." - (memoize #(keyword (str/replace (str (locale %)) "_" "-")))) + (or (try-jvm-locale loc lang-only?) + (throw (ex-info (str "Invalid locale: " loc) + {:loc loc :lang-only? lang-only?})))))) (comment - (mapv try-locale [nil :invalid :jvm-default :en-US :en-US-var1 (Locale/getDefault)]) - (time (dotimes [_ 10000] (locale :en))) - (mapv #(try-locale % :lang-only) - [nil :invalid :en-invalid :en-GB (Locale/getDefault)])) + (time (dotimes [_ 10000] (jvm-locale :en))) + (let [ls [nil :invalid :en-invalid :en-GB (Locale/getDefault)]] + [(map #(try-jvm-locale %) ls) + (map #(try-jvm-locale % :lang-only) ls)])) + +(def kw-locale + (memoize + (fn [?loc] + (let [loc-name (if-let [jvm-loc (try-jvm-locale ?loc)] + (str jvm-loc) + (name (or ?loc :nil)))] + (keyword (str/replace loc-name "_" "-")))))) + +(comment (map kw-locale [nil :whatever-foo :en (jvm-locale :en) "en-GB" + :jvm-default])) ;;;; Localization ;; The Java date API is a mess, but we (thankfully!) don't need much of it for @@ -117,7 +136,8 @@ :date (.format (f-date loc st1) dt) :time (.format (f-time loc st1) dt) :dt (.format (f-dt loc st1 st2) dt) - (throw (Exception. (str "Unknown style: " style)))))) + (throw (ex-info (str "Unknown style: " style) + {:style style}))))) Number (pfmt [n loc style] (case (or style :number) @@ -125,19 +145,20 @@ :integer (.format (f-integer loc) n) :percent (.format (f-percent loc) n) :currency (.format (f-currency loc) n) - (throw (Exception. (str "Unknown style: " style)))))) + (throw (ex-info (str "Unknown style: " style) + {:style style}))))) (defn fmt "Formats Date/Number as a string. `style` is <:#{date time dt}-#{default short medium long full}>, e.g. :date-full, :time-short, etc. (default :date-default)." - [loc x & [style]] (pfmt x (locale loc) style)) + [loc x & [style]] (pfmt x (jvm-locale loc) style)) (defn parse "Parses date/number string as a Date/Number. See `fmt` for possible `style`s (default :number)." [loc s & [style]] - (let [loc (locale loc) + (let [loc (jvm-locale loc) [type st1 st2] (parse-style style)] (case (or type :number) :number (.parse (f-number loc) s) @@ -148,16 +169,18 @@ :date (.parse (f-date loc st1) s) :time (.parse (f-time loc st1) s) :dt (.parse (f-dt loc st1 st2) s) - (throw (Exception. (str "Unknown style: " style)))))) + (throw (ex-info (str "Unknown style: " style) + {:style style}))))) (defmem- collator Collator [Loc] (Collator/getInstance Loc)) (defn lcomparator "Returns localized comparator." [loc & [style]] - (let [Col (collator (locale loc))] + (let [Col (collator (jvm-locale loc))] (case (or style :asc) :asc #(.compare Col %1 %2) :desc #(.compare Col %2 %1) - (throw (Exception. (str "Unknown style: " style)))))) + (throw (ex-info (str "Unknown style: " style) + {:style style}))))) (defn lsort "Localized sort. `style` e/o #{:asc :desc} (default :asc)." [loc coll & [style]] (sort (lcomparator loc style) coll)) @@ -189,7 +212,8 @@ :nfkc java.text.Normalizer$Form/NFKC :nfd java.text.Normalizer$Form/NFD :nfkd java.text.Normalizer$Form/NFKD - (throw (Exception. (format "Unrecognized normalization form: %s" form)))))) + (throw (ex-info (str "Unrecognized normalization form: " form) + {:form form}))))) (comment (normalize "hello" :invalid)) @@ -198,13 +222,13 @@ ;; (defmem- f-str Formatter [Loc] (Formatter. Loc)) (defn fmt-str "Like clojure.core/format but takes a locale." - ^String [loc fmt & args] (String/format (locale loc) fmt (to-array args))) + ^String [loc fmt & args] (String/format (jvm-locale loc) fmt (to-array args))) (defn fmt-msg "Creates a localized MessageFormat and uses it to format given pattern string, substituting arguments as per MessageFormat spec." ^String [loc ^String pattern & args] - (let [mformat (java.text.MessageFormat. pattern (locale loc))] + (let [mformat (java.text.MessageFormat. pattern (jvm-locale loc))] (.format mformat (to-array args)))) (comment @@ -225,7 +249,7 @@ "Returns { } sorted map." [iso-codes display-loc display-fn] (let [pairs (->> iso-codes (mapv (fn [code] [(display-fn code) code]))) - comparator (fn [ln-x ln-y] (.compare (collator (locale display-loc)) + comparator (fn [ln-x ln-y] (.compare (collator (jvm-locale display-loc)) ln-x ln-y))] (into (sorted-map-by comparator) pairs))) @@ -236,8 +260,9 @@ (memoize (fn ([loc] (countries loc iso-countries)) ([loc iso-countries] - (get-localized-sorted-map iso-countries (locale loc) - (fn [code] (.getDisplayCountry (Locale. "" (name code)) (locale loc)))))))) + (get-localized-sorted-map iso-countries (jvm-locale loc) + (fn [code] (.getDisplayCountry (Locale. "" (name code)) + (jvm-locale loc)))))))) (def iso-languages (->> (Locale/getISOLanguages) (mapv (comp keyword str/lower-case)) (set))) @@ -246,12 +271,12 @@ (memoize (fn ([loc] (languages loc iso-languages)) ([loc iso-languages] - (get-localized-sorted-map iso-languages (locale loc) + (get-localized-sorted-map iso-languages (jvm-locale loc) (fn [code] (let [Loc (Locale. (name code))] (str (.getDisplayLanguage Loc Loc) ; Lang, in itself - (when (not= Loc (locale loc :lang-only)) + (when (not= Loc (jvm-locale loc :lang-only)) (format " (%s)" ; Lang, in current lang - (.getDisplayLanguage Loc (locale loc)))))))))))) + (.getDisplayLanguage Loc (jvm-locale loc)))))))))))) (comment (countries :en) (languages :pl [:en :de :pl]) @@ -309,7 +334,7 @@ (def scoped "Merges scope keywords: (scope :a.b :c/d :e) => :a.b.c.d/e" (memoize (fn [& ks] (encore/merge-keywords ks)))) -(comment (scoped :a.b :c :d)) +(comment (scoped :a.b :c/d :e)) (def ^:dynamic *tscope* nil) (defmacro ^:also-cljs with-tscope @@ -338,7 +363,11 @@ :en-US {:example {:foo ":en-US :example/foo text"}} :de {:example {:foo ":de :example/foo text"}} :ja "test_ja.clj" ; Import locale's map from external resource - } + + ;; Dictionaries support arbitrary locale keys (need not be recognized as + ;; valid JVM Locales): + :arbitrary {:example {:foo ":arbitrary :example/foo text"}}} + :dev-mode? true ; Set to true for auto dictionary reloading :fallback-locale :de :scope-fn (fn [k] (scoped *tscope* k)) ; Experimental, undocumented @@ -354,8 +383,8 @@ (if-not (string? dict) dict (try (-> dict io/resource io/reader slurp read-string) (catch Exception e - (throw (Exception. (format "Failed to load dictionary from resource: %s" - dict) e)))))) + (throw (ex-info (str "Failed to load dictionary from resource: " dict) + {:dict dict} e)))))) (def loc-tree "Implementation detail. @@ -364,7 +393,7 @@ (let [loc-tree* (memoize (fn [loc] - (let [loc-parts (str/split (-> loc locale-key name) #"[-_]") + (let [loc-parts (str/split (-> loc kw-locale name) #"[-_]") loc-tree (mapv #(keyword (str/join "-" %)) (take-while identity (iterate butlast loc-parts)))] loc-tree))) @@ -386,6 +415,7 @@ (vec)))))))) (comment + (loc-tree [nil :whatever-foo :en]) ; [:nil :whatever-foo :whatever :en] (loc-tree :en-US) ; [:en-US :en] (loc-tree [:en-US]) ; [:en-US :en] (loc-tree [:en-GB :en-US]) ; [:en-GB :en-US :en] @@ -405,10 +435,13 @@ (assoc dict loc (dict-load (dict loc))))] [loc (apply encore/merge-deep (mapv dict (rseq loc-tree')))])))) -(comment (dict-inherit-parent-trs {:en {:foo ":en foo" - :bar ":en :bar"} - :en-US {:foo ":en-US foo"} - :ja "test_ja.clj"})) +(comment + (dict-inherit-parent-trs + {:en {:foo ":en foo" + :bar ":en :bar"} + :en-US {:foo ":en-US foo"} + :ja "test_ja.clj" + :arbitrary {:foo ":arbitrary :example/foo text"}})) (def ^:private dict-prepare (comp dict-inherit-parent-trs dict-load)) @@ -517,8 +550,11 @@ (fmt-fn loc1 pattern (nstr ls) (nstr (scope-fn nil)) (nstr ks))))))))] - (if (nil? fmt-args) tr - (if (nil? tr) (throw (Exception. "Can't format nil translation pattern")) + (if (nil? fmt-args) + tr + (if (nil? tr) + (throw (ex-info "Can't format nil translation pattern." + {:tr tr :fmt-args fmt-args})) (apply fmt-fn loc1 tr fmt-args)))))))) (def ^:private make-t-cached (memoize make-t-uncached)) @@ -537,6 +573,10 @@ (t :en example-tconfig [:invalid :example/foo]) (t :en example-tconfig [:invalid "Explicit fallback"]) + ;;; Invalid locales + (t nil example-tconfig :example/foo) + (t :invalid example-tconfig :example/foo) + (def prod-t (make-t (assoc example-tconfig :dev-mode? false))) (time (dotimes [_ 10000] (prod-t :en :example/foo))) ; ~18ms (time (dotimes [_ 10000] (prod-t :en [:invalid :example/foo]))) ; ~38ms @@ -556,7 +596,7 @@ (def ^:dynamic *locale* nil) (defmacro with-locale "DEPRECATED." - [loc & body] `(binding [*locale* (locale ~loc)] ~@body)) + [loc & body] `(binding [*locale* (jvm-locale ~loc)] ~@body)) (def ^:private migrate-tconfig (memoize @@ -581,7 +621,7 @@ (def fallback-locale "DEPRECATED." (atom :en)) (defn parse-Locale "DEPRECATED: Use `locale` instead." - [loc] (if (= loc :default) (locale :jvm-default) (locale loc))) + [loc] (if (= loc :default) (jvm-locale :jvm-default) (jvm-locale loc))) (defn l-compare "DEPRECATED." [x y] (.compare (collator *locale*) x y)) @@ -600,7 +640,8 @@ (defn style "DEPRECATED." ([] :default) ([style] (or (dt-styles style) - (throw (Exception. (str "Unknown style: " style)))))) + (throw (ex-info (str "Unknown style: " style) + {:style style}))))) (defn format-date "DEPRECATED." ([d] (fmt *locale* d :date)) @@ -655,11 +696,15 @@ (set-config! [:dict-res-name] resource-name) (encore/file-resources-modified? resource-name) (catch Exception e - (throw (Exception. (str "Failed to load dictionary from resource: " - resource-name) e)))))) + (throw (ex-info (str "Failed to load dictionary from resource: " + resource-name) + {:resource-name resource-name} e)))))) (defmacro with-scope "DEPRECATED." [translation-scope & body] `(with-tscope ~translation-scope ~@body)) ;; BREAKS v1 due to unavoidable name clash (def oldt #(apply t (or *locale* :jvm-default) (assoc @config :fmt-fn fmt-msg) %&)) + +(def locale "DEPRECATED as of v2.1.0." jvm-locale) +(def try-locale "DEPRECATED as of v2.1.0." try-jvm-locale) diff --git a/src/taoensso/tower.cljs b/src/taoensso/tower.cljs index 8e8648c..f4b5a30 100644 --- a/src/taoensso/tower.cljs +++ b/src/taoensso/tower.cljs @@ -1,48 +1,43 @@ (ns taoensso.tower - "Experimental ClojureScript support for Tower." + "Tower ClojureScript stuff - still pretty limited." {:author "Peter Taoussanis"} (:require-macros [taoensso.tower :as tower-macros]) (:require [clojure.string :as str] [taoensso.encore :as encore])) -;;;; TODO -;; * NB: Locale-aware format fn for fmt-str. -;; * Localization stuff? +;;;; Localization ; TODO Maybe later? ;;;; Utils -(def ^:crossover scoped (memoize (fn [& ks] (encore/merge-keywords ks)))) +(def scoped ; Crossover (direct) + (memoize (fn [& ks] (encore/merge-keywords ks)))) (defn- fmt-str "goog.string's `format` was removed from cljs.core 0.0-1885, Ref. http://goo.gl/su7Xkj" + ;; TODO Locale-aware format fn would be nice, but no obvious+easy way of + ;; implementing one to get Java-like semantics (?) [_loc fmt & args] (apply encore/format fmt args)) -;;;; Config +;;;; Translations -(def ^:dynamic *locale* nil) (def ^:dynamic *tscope* nil) -(def locale-key ; Crossover (modified) - (memoize #(keyword (str/replace (name %) #_(str (locale %)) "_" "-")))) - -(def locale locale-key) - -;;;; Localization - -;; Nothing here yet - -;;;; Translations - (comment ; Dictionaries (def my-dict-inline (tower-macros/dict-compile {:en {:a "**hello**"}})) (def my-dict-resource (tower-macros/dict-compile "slurps/i18n/utils.clj"))) +(def kw-locale ; Crossover (modified) + (memoize + (fn [?loc] + (let [loc-name (name (or ?loc :nil))] + (keyword (str/replace loc-name "_" "-")))))) + (def loc-tree ; Crossover (direct) (let [loc-tree* (memoize (fn [loc] - (let [loc-parts (str/split (-> loc locale-key name) #"[-_]") + (let [loc-parts (str/split (-> loc kw-locale name) #"[-_]") loc-tree (mapv #(keyword (str/join "-" %)) (take-while identity (iterate butlast loc-parts)))] loc-tree))) @@ -114,6 +109,9 @@ (fmt-fn loc1 pattern (nstr ls) (nstr (scope-fn nil)) (nstr ks))))))))] - (if (nil? fmt-args) tr - (if (nil? tr) (throw (js/Error. "Can't format nil translation pattern.")) + (if (nil? fmt-args) + tr + (if (nil? tr) + (throw (ex-info "Can't format nil translation pattern." + {:tr tr :fmt-args fmt-args})) (apply fmt-fn loc1 tr fmt-args)))))))) diff --git a/src/taoensso/tower/ring.clj b/src/taoensso/tower/ring.clj index c6d4cc0..994dd76 100644 --- a/src/taoensso/tower/ring.clj +++ b/src/taoensso/tower/ring.clj @@ -2,58 +2,67 @@ {:author "Peter Taoussanis"} (:require [clojure.string :as str] [taoensso.tower :as tower] - [taoensso.tower.utils :as utils])) + [taoensso.tower.utils :as utils] + [taoensso.encore :as encore])) (defn wrap-tower "Determines locale preference for request by attempting to parse a valid - locale from (locale-selector request), (:locale session), (:locale params), - request headers, etc. `locale-selector` can be used to select locale by IP - address, subdomain, TLD, etc. + locale from Ring request. `(fn locale-selector [ring-request])` can be used to + select locale(s) by IP address, subdomain, TLD, etc. Adds keys to Ring request: - * `:locale` - Preferred locale: `:en`, `:en-US`, etc. - * `:locales` - Accept-lang locales: `[:en-GB :en :en-US :fr-FR :fr]`, etc. - * `:tconfig` - tconfig map as given. - * `:t` - (fn [locale-or-locales k-or-ks & fmt-args]). - * `:t'` - (fn [k-or-ks & fmt-args]), using `:locales` as above." + * :locale - Preferred locale: `:en`, `:en-US`, etc. + * :locales - Desc-preference locales: `[:en-GB :en :en-US :fr-FR :fr]`, etc. + * :jvm-locale - As `:locale`, but a recognized JVM locale. + * :jvm-locales - As `:locales`, but only recognized JVM locales. + * :tconfig - tconfig map as given. + * :t - (fn [locale-or-locales k-or-ks & fmt-args]). + * :t' - (fn [k-or-ks & fmt-args]), using `:locales` as above." [handler tconfig & [{:keys [locale-selector fallback-locale] :or {fallback-locale :jvm-default} :as opts}]] (fn [{:keys [session params uri server-name headers] :as request}] (let [accept-lang-locales ; [:en-GB :en :en-US], etc. (->> (get headers "accept-language") (utils/parse-http-accept-header) - (mapv first) - (filterv tower/try-locale) - (mapv tower/locale-key)) + (mapv (comp tower/kw-locale first))) - preferred-locale ; Always used for formatting - (tower/locale-key - (some tower/try-locale + sorted-locales ; Quite expensive + (->> + (reduce (fn [v in] (if (sequential? in) (into v in) (conj v in))) + [] [(:locale request) - (when-let [ls locale-selector] (ls request)) + (when-let [ls locale-selector] + (ls request)) ; May return >=0 locales (:locale session) (:locale params) - (some identity accept-lang-locales) - fallback-locale])) + accept-lang-locales + fallback-locale]) + (filterv identity) + (mapv tower/kw-locale) + (encore/distinctv)) - t'-locales ; Ordered, non-distinct locales to search for translations - (-> (into [preferred-locale] accept-lang-locales) - (conj :jvm-default)) + sorted-jvm-locales (filter tower/try-jvm-locale sorted-locales) + + preferred-locale (first sorted-locales) + preferred-jvm-locale (first sorted-jvm-locales) t (tower/make-t tconfig) - t' (partial t t'-locales)] - (tower/with-locale preferred-locale ; Used for deprecated API + t' (partial t sorted-locales)] + + (tower/with-locale preferred-jvm-locale ; For deprecated API (handler - (merge request - {:locale preferred-locale - :locales accept-lang-locales - :tconfig tconfig} + (merge request + {:locale preferred-locale + :locales sorted-locales + :jvm-locale preferred-jvm-locale + :jvm-locales sorted-jvm-locales + :tconfig tconfig} - (if (:legacy-t? opts) - {:t t'} ; DEPRECATED (:t will use parsed locale) - {:t t ; Takes locale arg - :t' t' ; Uses parsed locale - }))))))) + (if (:legacy-t? opts) + {:t t'} ; DEPRECATED (:t will use parsed locale) + {:t t ; Takes locale arg + :t' t' ; Uses parsed locale + }))))))) ;;;; Deprecated diff --git a/test/taoensso/tower/tests/main.clj b/test/taoensso/tower/tests/main.clj index 4085d8d..c8074b9 100644 --- a/test/taoensso/tower/tests/main.clj +++ b/test/taoensso/tower/tests/main.clj @@ -181,6 +181,13 @@ (expect ":de :example/foo text" (pt [:zh :de] :example/foo)) (expect ":de :example/foo text" (pt [:zh :de] [:invalid :example/foo])) +;;; Arbitrary locales (translation API doesn't insist on JVM-recognized locales) +(expect ":arbitrary :example/foo text" (pt :arbitrary :example/foo)) + +;;; Invalid locales (translation API allows arbitrary locales to fallback like normal) +(expect ":de :example/foo text" (pt nil :example/foo)) +(expect ":de :example/foo text" (pt :invalid :example/foo)) + ;;; Aliases (expect "Hello Bob, how are you?" (pt :en :example/greeting-alias "Bob")) (expect (pt :en :example.bar/baz) (pt :en :example/baz-alias))