diff --git a/CHANGES.md b/CHANGES.md index ec60c5b..b6c9d66 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,18 +9,17 @@ This release moves the repository to [clj-commons](https://github.com/clj-common [Thura Hlaing](https://github.com/trhura)'s efforts in initiating this project. As part of this move, the Maven artifact coordinates have changed, as well as the namespaces. - ### Added - Tools build building and deployment - Github actions CI ### Changed -- Update dependencies to the most recent versions. -- Switched to deps.edn +- Update dependencies to the most recent versions +- Switched build to deps.edn ### Fixed -- Code cleanup (warnings generated by clj-kondo). -- Eliminate "already refers to" compiler warnings with Clojure 1.11.x. +- Code cleanup (warnings generated by clj-kondo) +- Eliminate "already refers to" compiler warnings with Clojure 1.11.x -[Closed Issues](https://github.com/trhura/clojure-humanize/milestone/3?closed=1) +[Closed Issues](https://github.com/clj-commons/humanize/milestone/3?closed=1) ## 0.2.2 - 15 Oct 2016 diff --git a/deps.edn b/deps.edn index 1031d77..50ded82 100644 --- a/deps.edn +++ b/deps.edn @@ -1,6 +1,7 @@ -{:deps {org.clojure/clojure {:mvn/version "1.11.1"} - clj-time/clj-time {:mvn/version "0.15.2"} - com.andrewmcveigh/cljs-time {:mvn/version "0.5.2"}} +{:deps {org.clojure/clojure {:mvn/version "1.11.1"} + clj-time/clj-time {:mvn/version "0.15.2"} + com.widdindustries/cljc.java-time {:mvn/version "0.1.21"} + henryw374/js-joda {:mvn/version "3.2.0-0"}} :paths ["src"] :aliases ;; clj -X:test @@ -11,7 +12,7 @@ :cljs-test {:extra-paths ["test"] :extra-deps {kongeor/cljs-test-runner {:git/url "https://github.com/kongeor/cljs-test-runner" - :sha "fa604e9e5f4e74a544958dfdf4c5ccc2a4b2c916"}} + :git/sha "fa604e9e5f4e74a544958dfdf4c5ccc2a4b2c916"}} :main-opts ["-m" "cljs-test-runner.main"]} :clj-kondo {:replace-deps {clj-kondo/clj-kondo {:mvn/version "RELEASE"}} :main-opts ["-m" "clj-kondo.main"]} diff --git a/src/clj_commons/humanize.cljc b/src/clj_commons/humanize.cljc index b2ddcd0..3627716 100644 --- a/src/clj_commons/humanize.cljc +++ b/src/clj_commons/humanize.cljc @@ -2,42 +2,34 @@ (:refer-clojure :exclude [abs]) (:require #?(:clj [clojure.math :as math :refer [floor round log log10]]) [clj-commons.humanize.inflect :refer [pluralize-noun in?]] + [clj-commons.humanize.time-convert :refer [coerce-to-local-date-time]] + [cljc.java-time.duration :as jt.duration] + [cljc.java-time.local-date-time :as jt.ldt] [clojure.string :as string :refer [join]] - #?(:clj [clj-commons.humanize.macros :refer [with-dt-diff]]) - #?(:clj [clj-time.core :refer [after? interval in-seconds - in-minutes in-hours in-days - in-weeks in-months in-years]] - :cljs [cljs-time.core :refer [after? interval in-seconds - in-minutes in-hours in-days - in-weeks in-months in-years]]) - #?(:cljs [goog.string :as gstring]) - #?(:cljs [goog.string.format]) - #?(:clj [clj-time.local :refer [local-now]] - :cljs [cljs-time.local :refer [local-now]]) - #?(:clj [clj-time.coerce :refer [to-date-time]] - :cljs [cljs-time.coerce :refer [to-date-time]])) - #?(:cljs (:require-macros [clj-commons.humanize.macros :refer [with-dt-diff]]))) + #?@(:cljs [[goog.string :as gstring] + [goog.string.format]]))) #?(:clj (def ^:private num-format format) :cljs (def ^:private num-format #(gstring/format %1 %2))) -#?(:clj (def ^:private expt math/pow)) -#?(:cljs (def ^:private expt (.-pow js/Math))) +#?(:clj (def ^:private expt math/pow) + :cljs (def ^:private expt (.-pow js/Math))) + #?(:cljs (def ^:private floor (.-floor js/Math))) #?(:cljs (def ^:private round (.-round js/Math))) -#?(:clj (def ^:private abs clojure.core/abs)) -#?(:cljs (def ^:private abs (.-abs js/Math))) +#?(:clj (def ^:private abs clojure.core/abs) + :cljs (def ^:private abs (.-abs js/Math))) #?(:cljs (def ^:private log (.-log js/Math))) #?(:cljs (def ^:private rounding-const 1000000)) -#?(:cljs (def ^:private log10 (or (.-log10 js/Math) ;; prefer native implementation - #(/ (.round js/Math - (* rounding-const - (/ (.log js/Math %) +#?(:cljs (def ^:private log10 (or (.-log10 js/Math) ;; prefer native implementation + #(/ (.round js/Math + (* rounding-const + (/ (.log js/Math %) js/Math.LN10))) - rounding-const)))) ;; FIXME rounding + rounding-const)))) ;; TODO: improve rounding here #?(:clj (def ^:private char->int #(Character/getNumericValue %)) :cljs (def ^:private char->int #(int %))) @@ -46,19 +38,18 @@ "Converts an integer to a string containing commas. every three digits. For example, 3000 becomes '3,000' and 45000 becomes '45,000'. " [num] - (let [ - decimal (abs (int num)) ;; FIXME: (abs ) - sign (if (< num 0) "-" "") + (let [decimal (abs (int num)) ;; FIXME: (abs ) + sign (if (< num 0) "-" "") ;; convert into string representation - repr (str decimal) - repr-len (count repr) + repr (str decimal) + repr-len (count repr) ;; right-aligned 3 elements partition partitioned [(subs repr 0 (rem repr-len 3)) (map #(apply str %) - (partition 3 (subs repr - (rem repr-len 3))))] + (partition 3 (subs repr + (rem repr-len 3))))] ;; flatten, and remove empty string partitioned (remove empty? (flatten partitioned))] @@ -70,19 +61,19 @@ "Converts an integer to its ordinal as a string. 1 is '1st', 2 is '2nd', 3 is '3rd', etc." [num] - (let [ordinals ["th", "st", "nd", "rd", "th", - "th", "th", "th", "th", "th"] - remainder-100 (rem num 100) - remainder-10 (rem num 10)] + (let [ordinals ["th", "st", "nd", "rd", "th", + "th", "th", "th", "th", "th"] + remainder-100 (rem num 100) + remainder-10 (rem num 10)] - (if (in? remainder-100 [11 12 13]) - ;; special case for *11, *12, *13 - (str num (ordinals 0)) - (str num (ordinals remainder-10))))) + (if (in? remainder-100 [11 12 13]) + ;; special case for *11, *12, *13 + (str num (ordinals 0)) + (str num (ordinals remainder-10))))) -(defn logn [num base] +(defn- logn [num base] (/ (round (log num)) - (round (log base)))) + (round (log base)))) (def ^:private human-pows [[100 " googol"] @@ -106,17 +97,38 @@ [num & {:keys [format] :or {format "%.1f"}}] (let [base-pow (int (floor (log10 num))) [base-pow suffix] (first (filter (fn [[base _]] (>= base-pow base)) human-pows)) - value (float (/ num (expt 10 base-pow)))] + value (float (/ num (expt 10 base-pow)))] (str (num-format format value) suffix))) - (def ^:private numap - {0 "",1 "one",2 "two",3 "three",4 "four",5 "five", - 6 "six",7 "seven",8 "eight",9 "nine",10 "ten", - 11 "eleven",12 "twelve",13 "thirteen",14 "fourteen", - 15 "fifteen",16 "sixteen",17 "seventeen",18 "eighteen", - 19 "nineteen",20 "twenty",30 "thirty",40 "forty", - 50 "fifty",60 "sixty",70 "seventy",80 "eighty",90 "ninety"}) + {0 "" + 1 "one" + 2 "two" + 3 "three" + 4 "four" + 5 "five" + 6 "six" + 7 "seven" + 8 "eight" + 9 "nine" + 10 "ten" + 11 "eleven" + 12 "twelve" + 13 "thirteen" + 14 "fourteen" + 15 "fifteen" + 16 "sixteen" + 17 "seventeen" + 18 "eighteen" + 19 "nineteen" + 20 "twenty" + 30 "thirty" + 40 "forty" + 50 "fifty" + 60 "sixty" + 70 "seventy" + 80 "eighty" + 90 "ninety"}) (defn numberword "Takes a number and return a full written string form. For example, @@ -128,87 +140,84 @@ (if (zero? num) "zero" - (let [digitcnt (int (log10 num)) - divisible? (fn [num div] (zero? (rem num div))) - n-digit (fn [num n] (char->int (.charAt (str num) n)))] ;; TODO rename - - (cond - ;; handle million part - (>= digitcnt 6) (if (divisible? num 1000000) - (join " " [(numberword (int (/ num 1000000))) - "million"]) - (join " " [(numberword (int (/ num 1000000))) - "million" - (numberword (rem num 1000000))])) - - ;; handle thousand part - (>= digitcnt 3) (if (divisible? num 1000) - (join " " [(numberword (int (/ num 1000))) - "thousand"]) - (join " " [(numberword (int (/ num 1000))) - "thousand" - (numberword (rem num 1000))])) - - ;; handle hundred part - (>= digitcnt 2) (if (divisible? num 100) - (join " " [(numap (int (/ num 100))) - "hundred"]) - (join " " [(numap (int (/ num 100))) - "hundred" - "and" - (numberword (rem num 100))])) - - ;; handle the last two digits - (< num 20) (numap num) - (divisible? num 10) (numap num) - :else (join "-" [(numap (* 10 (n-digit num 0))) - (numap (n-digit num 1))]))))) + (let [digitcnt (int (log10 num)) + divisible? (fn [num div] (zero? (rem num div))) + n-digit (fn [num n] (char->int (.charAt (str num) n)))] ;; TODO rename + + (cond + ;; handle million part + (>= digitcnt 6) (if (divisible? num 1000000) + (join " " [(numberword (int (/ num 1000000))) + "million"]) + (join " " [(numberword (int (/ num 1000000))) + "million" + (numberword (rem num 1000000))])) + + ;; handle thousand part + (>= digitcnt 3) (if (divisible? num 1000) + (join " " [(numberword (int (/ num 1000))) + "thousand"]) + (join " " [(numberword (int (/ num 1000))) + "thousand" + (numberword (rem num 1000))])) + + ;; handle hundred part + (>= digitcnt 2) (if (divisible? num 100) + (join " " [(numap (int (/ num 100))) + "hundred"]) + (join " " [(numap (int (/ num 100))) + "hundred" + "and" + (numberword (rem num 100))])) + + ;; handle the last two digits + (< num 20) (numap num) + (divisible? num 10) (numap num) + :else (join "-" [(numap (* 10 (n-digit num 0))) + (numap (n-digit num 1))]))))) + +(def ^:private decimal-sizes [:B :KB :MB :GB :TB :PB :EB :ZB :YB]) + +(def ^:private binary-sizes [:B :KiB :MiB :GiB :TiB :PiB :EiB :ZiB :YiB]) (defn filesize "Format a number of bytes as a human readable filesize (eg. 10 kB). By default, decimal suffixes (kB, MB) are used. Passing :binary true will use binary suffixes (KiB, MiB) instead." [bytes & {:keys [binary format] - :or {binary false - format "%.1f"}}] + :or {binary false + format "%.1f"}}] (if (zero? bytes) ;; special case for zero "0" - (let [decimal-sizes [:B, :KB, :MB, :GB, :TB, - :PB, :EB, :ZB, :YB] - binary-sizes [:B, :KiB, :MiB, :GiB, :TiB, - :PiB, :EiB, :ZiB, :YiB] - - units (if binary binary-sizes decimal-sizes) - base (if binary 1024 1000) + (let [units (if binary binary-sizes decimal-sizes) + base (if binary 1024 1000) - base-pow (int (floor (logn bytes base))) - ;; if base power shouldn't be larger than biggest unit - base-pow (if (< base-pow (count units)) - base-pow - (dec (count units))) - suffix (name (get units base-pow)) - value (float (/ bytes (expt base base-pow))) - ] - - (str (num-format format value) suffix)))) + base-pow (int (floor (logn bytes base))) + ;; if base power shouldn't be larger than biggest unit + base-pow (if (< base-pow (count units)) + base-pow + (dec (count units))) + suffix (name (get units base-pow)) + value (float (/ bytes (expt base base-pow)))] + (str (num-format format value) suffix)))) (defn truncate "Truncate a string with suffix (ellipsis by default) if it is longer than specified length." ([string length suffix] - (let [string-len (count string) - suffix-len (count suffix)] + (let [string-len (count string) + suffix-len (count suffix)] - (if (<= string-len length) - string - (str (subs string 0 (- length suffix-len)) suffix)))) + (if (<= string-len length) + string + (str (subs string 0 (- length suffix-len)) suffix)))) ([string length] - (truncate string length "..."))) + (truncate string length "…"))) (defn oxford "Converts a list of items to a human-readable string, such as \"apple, pear, and 2 other fruits\". @@ -220,20 +229,20 @@ :number-format - function used to format the number of additional items in the list (default: `str`) " [coll & {:keys [maximum-display truncate-noun number-format] - :or {maximum-display 4 - number-format str}}] + :or {maximum-display 4 + number-format str}}] (let [coll-length (count coll)] (cond - ;; if coll has one or zero items + ;; if coll has one or zero items (< coll-length 2) (join coll) - ;; if coll has exactly two items, there won't be a comma, so join them with "and" + ;; if coll has exactly two items, there won't be a comma, so join them with "and" (and (= coll-length 2) - (<= coll-length maximum-display)) + (<= coll-length maximum-display)) (str (first coll) " and " (second coll)) - ;; if the number of items doesn't exceed maximum display size + ;; if the number of items doesn't exceed maximum display size (<= coll-length maximum-display) (let [before-last (take (dec coll-length) coll) last-item (last coll)] (str (join (interpose ", " before-last)) @@ -241,79 +250,100 @@ (> coll-length maximum-display) (let [display-coll (take maximum-display coll) remaining (- coll-length maximum-display) - remaining' (number-format remaining) + remaining' (number-format remaining) last-item (if (string/blank? truncate-noun) (str remaining' " " (pluralize-noun remaining "other")) (str remaining' " other " (pluralize-noun remaining - truncate-noun)))] + truncate-noun)))] (str (join (interpose ", " display-coll)) ; if only one item is displayed there should be no oxford comma (when-not (= 1 maximum-display) ",") " and " last-item)) - ;; TODO: shouldn't reach here, throw exception + ;; TODO: shouldn't reach here, throw exception :else coll-length))) -(defn- in-decades [diff] - (/ (in-years diff) 10)) - -(defn- in-centuries [diff] - (/ (in-years diff) 100)) - -(defn- in-millennia [diff] - (/ (in-years diff) 1000)) +(def ^:private one-minute-in-seconds 60) +(def ^:private one-hour-in-seconds (* 60 one-minute-in-seconds)) +(def ^:private one-day-in-seconds (* 24 one-hour-in-seconds)) +(def ^:private one-week-in-seconds (* 7 one-day-in-seconds)) +(def ^:private one-month-in-seconds (* 4 one-week-in-seconds)) +(def ^:private one-year-in-seconds (* 52 one-week-in-seconds)) +(def ^:private one-decade-in-seconds (* 10 one-year-in-seconds)) +(def ^:private one-century-in-seconds (* 100 one-year-in-seconds)) +(def ^:private one-millennia-in-seconds (* 1000 one-year-in-seconds)) + +(defn format-delta-str + [amount time-unit suffix prefix future-time?] + (if future-time? + (str prefix " " amount " " (pluralize-noun amount time-unit)) + (str amount " " (pluralize-noun amount time-unit) " " suffix))) (defn datetime - "Given a datetime or date, return a human-friendly representation - of the amount of time elapsed. " + "Given a java.time.LocalDate or java.time.LocalDateTime, returns a + human-friendly representation of the amount of time elapsed compared to now. + + Optional keyword args: + * :now-dt - specify the value for 'now' + * :prefix - adjust the verbiage for times in the future + * :suffix - adjust the verbiage for times in the past" [then-dt & {:keys [now-dt suffix prefix] - :or {now-dt (local-now) - suffix "ago" - prefix "in"}}] - (let [then-dt (to-date-time then-dt) - now-dt (to-date-time now-dt) - future-time? (after? then-dt now-dt) - diff (if future-time? - (interval now-dt then-dt) - (interval then-dt now-dt))] + :or {now-dt (jt.ldt/now) + suffix "ago" + prefix "in"}}] + (let [then-dt (coerce-to-local-date-time then-dt) + now-dt (coerce-to-local-date-time now-dt) + future-time? (jt.ldt/is-after then-dt now-dt) + ;; get the Duration between the two times + time-between (-> (jt.duration/between then-dt now-dt) + (jt.duration/abs)) + delta-in-seconds (jt.duration/get-seconds time-between) + delta-in-minutes (int (/ delta-in-seconds one-minute-in-seconds)) + delta-in-hours (int (/ delta-in-seconds one-hour-in-seconds)) + delta-in-days (int (/ delta-in-seconds one-day-in-seconds)) + delta-in-weeks (int (/ delta-in-seconds one-week-in-seconds)) + delta-in-months (int (/ delta-in-seconds one-month-in-seconds)) + delta-in-years (int (/ delta-in-seconds one-year-in-seconds)) + delta-in-decades (int (/ delta-in-seconds one-decade-in-seconds)) + delta-in-centuries (int (/ delta-in-seconds one-century-in-seconds)) + delta-in-millennia (int (/ delta-in-seconds one-millennia-in-seconds))] (cond + (pos? delta-in-millennia) + (format-delta-str delta-in-millennia "millenium" suffix prefix future-time?) - ;; if the diff is greater than a millennium - (>= (in-millennia diff) 1) (with-dt-diff in-millennia diff "millenium" future-time? prefix suffix) + (pos? delta-in-centuries) + (format-delta-str delta-in-centuries "century" suffix prefix future-time?) - ;; if the diff is less than a millennium - (>= (in-centuries diff) 1) (with-dt-diff in-centuries diff "century" future-time? prefix suffix) + (pos? delta-in-decades) + (format-delta-str delta-in-decades "decade" suffix prefix future-time?) - ;; if the diff is less than a century - (>= (in-decades diff) 1) (with-dt-diff in-decades diff "decade" future-time? prefix suffix) + (pos? delta-in-years) + (format-delta-str delta-in-years "year" suffix prefix future-time?) - ;; if the diff is less than a decade - (>= (in-years diff) 1) (with-dt-diff in-years diff "year" future-time? prefix suffix) + (pos? delta-in-months) + (format-delta-str delta-in-months "month" suffix prefix future-time?) - ;; if the diff is less than a year - (>= (in-months diff) 1) (with-dt-diff in-months diff "month" future-time? prefix suffix) + (pos? delta-in-weeks) + (format-delta-str delta-in-weeks "week" suffix prefix future-time?) - ;; if the diff is less than a month - (>= (in-weeks diff) 1) (with-dt-diff in-weeks diff "week" future-time? prefix suffix) + (pos? delta-in-days) + (format-delta-str delta-in-days "day" suffix prefix future-time?) - ;; if the diff is less than a week - (>= (in-days diff) 1) (with-dt-diff in-days diff "day" future-time? prefix suffix) + (pos? delta-in-hours) + (format-delta-str delta-in-hours "hour" suffix prefix future-time?) - ;; if the diff is less than a day - (>= (in-hours diff) 1) (with-dt-diff in-hours diff "hour" future-time? prefix suffix) + (pos? delta-in-minutes) + (format-delta-str delta-in-minutes "minute" suffix prefix future-time?) - ;; if the diff is less than an hour - (>= (in-minutes diff) 1) (with-dt-diff in-minutes diff "minute" future-time? prefix suffix) + (pos? delta-in-seconds) + (format-delta-str delta-in-seconds "second" suffix prefix future-time?) - ;; if the diff is less than a minute - (>= (in-seconds diff) 1) (with-dt-diff in-seconds diff "second" future-time? prefix suffix) + future-time? + (str prefix " a moment") - ;; if the diff is less than a second - :else (if future-time? - (str prefix " a moment") - (str "a moment " suffix)) - ))) + :else + (str "a moment " suffix)))) (def ^:private duration-periods [[(* 1000 60 60 24 365) "year"] @@ -335,7 +365,7 @@ {:pre [(<= 0 duration-ms)]} (loop [remainder duration-ms [[period-ms period-name] & more-periods] duration-periods - terms []] + terms []] (cond (nil? period-ms) terms @@ -344,10 +374,10 @@ (recur remainder more-periods terms) :else - (let [period-count (int (/ remainder period-ms)) + (let [period-count (int (/ remainder period-ms)) next-remainder (mod remainder period-ms)] (recur next-remainder more-periods - (conj terms [period-count period-name])))))) + (conj terms [period-count period-name])))))) (defn duration "Converts duration, in milliseconds, into a string describing it in terms @@ -373,15 +403,15 @@ ([duration-ms options] (let [terms (duration-terms duration-ms) {:keys [number-format list-format short-text] - :or {number-format numberword - short-text "less than a second" - ;; This default, instead of oxford, because the entire string is a single "value" - list-format #(join ", " %)}} options] + :or {number-format numberword + short-text "less than a second" + ;; This default, instead of oxford, because the entire string is a single "value" + list-format #(join ", " %)}} options] (if (seq terms) (->> terms - (map (fn [[period-count period-name]] - (str (number-format period-count) - " " - (pluralize-noun period-count period-name)))) - list-format) + (map (fn [[period-count period-name]] + (str (number-format period-count) + " " + (pluralize-noun period-count period-name)))) + list-format) short-text)))) diff --git a/src/clj_commons/humanize/macros.cljc b/src/clj_commons/humanize/macros.cljc deleted file mode 100644 index 8fb1e81..0000000 --- a/src/clj_commons/humanize/macros.cljc +++ /dev/null @@ -1,10 +0,0 @@ -(ns ^:no-doc clj-commons.humanize.macros - "Private namespace for implementation details." - (:require [clj-commons.humanize.inflect :refer [pluralize-noun]])) - -(defmacro with-dt-diff [desc-diff diff desc-type future-time? prefix suffix] - `(let [d# (~desc-diff ~diff) - t# (pluralize-noun (~desc-diff ~diff) ~desc-type)] - (if ~future-time? - (str ~prefix " " d# " " t#) - (str d# " " t# " " ~suffix)))) diff --git a/src/clj_commons/humanize/time_convert.cljc b/src/clj_commons/humanize/time_convert.cljc new file mode 100644 index 0000000..052d653 --- /dev/null +++ b/src/clj_commons/humanize/time_convert.cljc @@ -0,0 +1,47 @@ +(ns ^:no-doc clj-commons.humanize.time-convert + "Internal utility to convert strings and other typs into LocalDateTime " + (:require [cljc.java-time.extn.predicates :as jt.predicates] + [cljc.java-time.format.date-time-formatter :as dt.formats] + [cljc.java-time.local-date-time :as jt.ldt] + #?(:clj [clj-commons.humanize.time-convert.jvm :as jvm]))) + +(defn- looks-like-an-iso8601-string? + [s] + (and (string? s) + (boolean (re-matches #"^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d$" s)))) + +(defn- looks-like-a-date-string? + [s] + (and (string? s) + (boolean (re-matches #"^\d\d\d\d-\d\d-\d\d$" s)))) + +(defn coerce-to-local-date-time + "Does its best to convert t into a java.time.LocalDateTime object. + Accepts: + - java.time.LocalDateTime and java.time.LocalDate + - java.util.Date (on the JVM) + - Strings in 'yyyy-MM-dd' and 'yyyy-MM-ddTHH:MM:SS' formats + + Throws an Exception if unable to convert." + [t] + (cond + ;; t is already a java.time.LocalDateTime + (jt.predicates/local-date-time? t) t + + #?@(:clj [(jt.predicates/local-date? t) + (jt.ldt/parse (jvm/java-time-local-date->iso8601-str t) dt.formats/iso-date-time) + + (jvm/java-util-date? t) + (jt.ldt/parse (jvm/java-util-date->iso8601-str t) dt.formats/iso-date-time)]) + + ;; Strings + (looks-like-an-iso8601-string? t) + (jt.ldt/parse t dt.formats/iso-date-time) + + (looks-like-a-date-string? t) + (jt.ldt/parse (str t "T00:00:00") dt.formats/iso-date-time) + + ;; ¯\_(ツ)_/¯ + :else + (throw (ex-info "unable to coerce to java.time.LocalDateTime" + {:value t})))) diff --git a/src/clj_commons/humanize/time_convert/jvm.cljc b/src/clj_commons/humanize/time_convert/jvm.cljc new file mode 100644 index 0000000..6766b0d --- /dev/null +++ b/src/clj_commons/humanize/time_convert/jvm.cljc @@ -0,0 +1,19 @@ +(ns ^:no-doc clj-commons.humanize.time-convert.jvm + "Separate out the JVM-only checks and conversions." + (:import (java.util Date) + (java.time LocalDate) + (java.text SimpleDateFormat))) + +(defn java-util-date? [d] + (instance? Date d)) + +(def ^:private java-util-date-iso8601-formatter + (SimpleDateFormat. "yyyy-MM-dd'T'HH:mm:ss")) + +(defn java-util-date->iso8601-str + ^String [^Date date] + (.format java-util-date-iso8601-formatter date)) + +(defn java-time-local-date->iso8601-str + ^String [^LocalDate date] + (str (.toString date) "T00:00:00")) diff --git a/test/clj_commons/humanize_test.cljc b/test/clj_commons/humanize_test.cljc index 0e3b8fd..345c1be 100644 --- a/test/clj_commons/humanize_test.cljc +++ b/test/clj_commons/humanize_test.cljc @@ -1,122 +1,111 @@ (ns clj-commons.humanize-test - (:require #?(:clj [clojure.test :refer :all] + (:require #?(:clj [clojure.test :refer [deftest testing is are]] :cljs [cljs.test :refer-macros [deftest testing is are]]) [clj-commons.humanize :refer [intcomma ordinal intword numberword - filesize truncate oxford datetime - duration] + filesize truncate oxford datetime + duration] :as h] - [clj-commons.humanize.inflect :refer [pluralize-noun]] - #?(:clj [clojure.math :as math]) - #?(:clj [clj-time.core :refer [now from-now seconds millis minutes - hours days weeks months years plus]] - :cljs [cljs-time.core :refer [now from-now seconds millis minutes - hours days weeks months years plus]]) - #?(:clj [clj-time.local :refer [local-now]] - :cljs [cljs-time.local :refer [local-now]]) - #?(:clj [clj-time.coerce :refer [to-date-time to-string]] - :cljs [cljs-time.coerce :refer [to-date-time to-string]]))) - -#?(:clj (def ^:private expt math/pow) + #?@(:clj [[clojure.math :as math]]) + [cljc.java-time.local-date-time :as jt.ldt])) + +#?(:clj (def ^:private expt math/pow) :cljs (def ^:private expt (.-pow js/Math))) (deftest intcomma-test - (testing "Testing intcomma function with expected data." - (doseq [[testnum result] [[100, "100"], [1000, "1,000"], - [10123, "10,123"], [10311, "10,311"], - [1000000, "1,000,000"], [-100, "-100"], - [-10123 "-10,123"], [-10311 "-10,311"], - [-1000000, "-1,000,000"]]] - (is (= (intcomma testnum) result))))) + (are [input expected] (= expected (intcomma input)) + 100, "100" + 1000 "1,000", + 10123 "10,123" + 10311 "10,311" + 1000000 "1,000,000" + -100 "-100" + -10123 "-10,123" + -10311 "-10,311", + -1000000 "-1,000,000")) (deftest ordinal-test - (testing "Testing ordinal function with expected data." - (doseq [[testnum result] [[1,"1st"], [ 2,"2nd"], - [ 3,"3rd"], [ 4,"4th"], - [ 11,"11th"],[ 12,"12th"], - [ 13,"13th"], [ 101,"101st"], - [ 102,"102nd"], [ 103,"103rd"], - [111, "111th"]]] - (is (= (ordinal testnum) result))))) + (are [input expected] (= expected (ordinal input)) + 1 "1st" + 2 "2nd" + 3 "3rd" + 4 "4th" + 11 "11th" + 12 "12th" + 13 "13th" + 101 "101st" + 102 "102nd" + 103 "103rd" + 111 "111th")) (deftest intword-test - (testing "Testing intword function with expected data." - (doseq [[testnum result format] [[100 "100.0"] - [ 1000000 "1.0 million"] - [ 1200000 "1.2 million"] - [ 1290000 "1.3 million"] - [ 1000000000 "1.0 billion"] - [ 2000000000 "2.0 billion"] - [ 6000000000000 "6.0 trillion"] - [1300000000000000 "1.3 quadrillion"] - [3500000000000000000000 "3.5 sextillion"] - [8100000000000000000000000000000000 "8.1 decillion"] - [1230000 "1.23 million" "%.2f"] - [(expt 10 101) "10.0 googol"] - ]] - ;; default argument - (let [format (if (nil? format) "%.1f" format)] - (is (= (intword testnum - :format format - ) - result)))))) + (are [input format expected] (= expected (if format + (intword input :format format) + (intword input))) + 100 nil "100.0" + 1000000 nil "1.0 million" + 1200000 nil "1.2 million" + 1290000 nil "1.3 million" + 1000000000 nil "1.0 billion" + 2000000000 nil "2.0 billion" + 6000000000000 nil "6.0 trillion" + 1300000000000000 nil "1.3 quadrillion" + 3500000000000000000000 nil "3.5 sextillion" + 8100000000000000000000000000000000 nil "8.1 decillion" + 1230000 "%.2f" "1.23 million" + (expt 10 101) nil "10.0 googol")) (deftest numberword-test - (testing "Testing numberword function with expected data." - (doseq [[testnum result] [[0 "zero"] - [7 "seven"] - [12 "twelve"] - [40 "forty"] - [94 "ninety-four"] - [100 "one hundred"] - [51 "fifty-one"] - [234 "two hundred and thirty-four"] - [1000 "one thousand"] - [3567 "three thousand five hundred and sixty-seven"] - [44120 "forty-four thousand one hundred and twenty"] - [25223 "twenty-five thousand two hundred and twenty-three"] - [5223 "five thousand two hundred and twenty-three"] - [1000000 "one million"] - [23237897 "twenty-three million two hundred and thirty-seven thousand eight hundred and ninety-seven"]]] - ;; default argument - (is (= (numberword testnum) result))))) + (are [input expected] (= expected (numberword input)) + 0 "zero" + 7 "seven" + 12 "twelve" + 40 "forty" + 94 "ninety-four" + 100 "one hundred" + 51 "fifty-one" + 234 "two hundred and thirty-four" + 1000 "one thousand" + 3567 "three thousand five hundred and sixty-seven" + 44120 "forty-four thousand one hundred and twenty" + 25223 "twenty-five thousand two hundred and twenty-three" + 5223 "five thousand two hundred and twenty-three" + 1000000 "one million" + 23237897 "twenty-three million two hundred and thirty-seven thousand eight hundred and ninety-seven")) (deftest filesize-test - (testing "Testing filesize function with expected data." - (doseq [[testsize result binary format] [[0, "0"] - [300, "300.0B"] - [3000, "3.0KB"] - [3000000, "3.0MB"] - [3000000000, "3.0GB"] - [3000000000000, "3.0TB"] - [3000, "2.9KiB", true] - [3000000, "2.9MiB", true] - [(* (expt 10 26) 30), "3000.0YB"] - [(* (expt 10 26) 30), "2481.5YiB", true] - - ]] - ;; default argument - (let [binary (boolean binary) - format (if (nil? format) "%.1f" format)] - (is (= (filesize testsize - :binary binary - :format format - ) - result)))))) + (let [f (fn [input binary format] + (apply filesize input + (cond-> [] + binary (conj :binary true) + format (conj :format format))))] + (are [input binary format expected] (is (= expected (f input binary format))) + 0 nil nil "0" + 300 nil nil "300.0B" + 3000 nil nil "3.0KB" + 3000000 nil nil "3.0MB" + 3005000 nil "%.3f" "3.005MB" + 3000000000 nil nil "3.0GB" + 3000000000000 nil nil "3.0TB" + 3000 true nil "2.9KiB" + 3000000 true nil "2.9MiB" + (* (expt 10 26) 30) nil nil "3000.0YB" + (* (expt 10 26) 30) true nil "2481.5YiB"))) (deftest truncate-test (testing "truncate should not return a string larger than the given length." - (let [string "asdfghjkl" ] + (let [string "asdfghjkl"] (is (= (count (truncate string 7)) 7)) (is (= (count (truncate string 7 "1234")) 7)) (is (= (count (truncate string 100)) (count string))))) (testing "testing truncate with expected data." (let [string "abcdefghijklmnopqrstuvwxyz"] - (is (= (truncate string 14) "abcdefghijk...")) + ;; Strng is truncated to a total of 14 including the (default) suffix: + (is (= (truncate string 14) "abcdefghijklm…")) (is (= (truncate string 14 "...kidding") "abcd...kidding"))))) (deftest oxford-test - (let [items ["apple", "orange", "banana", "pear", "pineapple", "strawberry"]] + (let [items ["apple", "orange" "banana" "pear" "pineapple" "strawberry"]] (testing "should return an empty string when given an empty list." (is (= "" (oxford [])))) @@ -140,19 +129,19 @@ (is (= "apple, orange, and 3 others" (oxford (take 5 items) - :maximum-display 2))) + :maximum-display 2))) (is (= "apple and 3 others" (oxford (take 4 items) - :maximum-display 1)))) + :maximum-display 1)))) (testing "should use custom truncation nouns" (let [truncate-noun "fruit"] (is (= "apple, orange, banana, pear, and 2 other fruits" (oxford items - :truncate-noun truncate-noun))) + :truncate-noun truncate-noun))) (is (= "apple, orange, and banana" (oxford (take 3 items) - :truncate-noun truncate-noun))))) + :truncate-noun truncate-noun))))) (testing "should allow for different output conversion for the extra item count" (is (= "apple, orange, and four others" @@ -166,54 +155,103 @@ :truncate-noun "fruit" :number-format numberword)))))) +(def datetime-test-phrases + (let [one-decade-in-years 10 + one-century-in-years 100 + one-millenia-in-years 1000] + [["a moment ago" identity] + ["a moment ago" #(jt.ldt/minus-nanos % 1000)] + ["in a moment" #(jt.ldt/plus-nanos % 1000)] + + ["10 seconds ago" #(jt.ldt/minus-seconds % 10)] + ["1 second ago" #(jt.ldt/minus-seconds % 1)] + ["in 10 seconds" #(jt.ldt/plus-seconds % 10)] + ["in 1 second" #(jt.ldt/plus-seconds % 1)] + + ["10 minutes ago" #(jt.ldt/minus-minutes % 10)] + ["in 10 minutes" #(jt.ldt/plus-minutes % 10)] + ["1 minute ago" #(jt.ldt/minus-minutes % 1)] + ["in 1 minute" #(jt.ldt/plus-minutes % 1)] + + ["10 hours ago" #(jt.ldt/minus-hours % 10)] + ["in 10 hours" #(jt.ldt/plus-hours % 10)] + ["1 hour ago" #(jt.ldt/minus-hours % 1)] + ["in 1 hour" #(jt.ldt/plus-hours % 1)] + + ["5 days ago" #(jt.ldt/minus-days % 5)] + ["in 5 days" #(jt.ldt/plus-days % 5)] + ["1 day ago" #(jt.ldt/minus-days % 1)] + ["in 1 day" #(jt.ldt/plus-days % 1)] + + ["3 weeks ago" #(jt.ldt/minus-weeks % 3)] + ["in 3 weeks" #(jt.ldt/plus-weeks % 3)] + ["1 week ago" #(jt.ldt/minus-weeks % 1)] + ["in 1 week" #(jt.ldt/plus-weeks % 1)] + + ["2 months ago" #(jt.ldt/minus-months % 2)] + ["in 2 months" #(jt.ldt/plus-months % 2)] + ["10 months ago" #(jt.ldt/minus-months % 10)] + ["in 10 months" #(jt.ldt/plus-months % 10)] + ["1 month ago" #(jt.ldt/minus-months % 1)] + ["in 1 month" #(jt.ldt/plus-months % 1)] + + ["3 years ago" #(jt.ldt/minus-years % 3)] + ["in 3 years" #(jt.ldt/plus-years % 3)] + ["1 year ago" #(jt.ldt/minus-years % 1)] + ["in 1 year" #(jt.ldt/plus-years % 1)] + + ["3 decades ago" #(jt.ldt/minus-years % (* 3 one-decade-in-years))] + ["in 3 decades" #(jt.ldt/plus-years % (* 3 one-decade-in-years))] + ["1 decade ago" #(jt.ldt/minus-years % one-decade-in-years)] + ["in 1 decade" #(jt.ldt/plus-years % one-decade-in-years)] + + ["3 centuries ago" #(jt.ldt/minus-years % (* 3 one-century-in-years))] + ["in 3 centuries" #(jt.ldt/plus-years % (* 3 one-century-in-years))] + ["1 century ago" #(jt.ldt/minus-years % one-century-in-years)] + ["in 1 century" #(jt.ldt/plus-years % one-century-in-years)] + + ["3 millennia ago" #(jt.ldt/minus-years % (* 3 one-millenia-in-years))] + ["in 3 millennia" #(jt.ldt/plus-years % (* 3 one-millenia-in-years))] + ["1 millenium ago" #(jt.ldt/minus-years % one-millenia-in-years)] + ["in 1 millenium" #(jt.ldt/plus-years % one-millenia-in-years)]])) + (deftest datetime-test - (let [past (fn [n unit] (datetime (now) :now-dt (-> n unit from-now))) - future (fn [n unit] (datetime (plus (-> n unit from-now) (millis 300)) ; fix delayed execution by adding some millis - :now-dt (now)))] - (testing "date diff to text" - (are [expected diff] (= expected diff) - "a moment ago" (datetime (now)) - "in a moment" (datetime (-> 500 millis from-now)) - "10 seconds ago" (past 10 seconds) - "in 10 seconds" (future 10 seconds) - "1 second ago" (past 1 seconds) - "in 1 second" (future 1 seconds) - "10 minutes ago" (past 10 minutes) - "in 10 minutes" (future 10 minutes) - "1 minute ago" (past 1 minutes) - "in 1 minute" (future 1 minutes) - "10 hours ago" (past 10 hours) - "in 10 hours" (future 10 hours) - "1 hour ago" (past 1 hours) - "in 1 hour" (future 1 hours) - "5 days ago" (past 5 days) - "in 5 days" (future 5 days) - "1 day ago" (past 1 days) - "in 1 day" (future 1 days) - "1 week ago" (past 1 weeks) - "in 1 week" (future 1 weeks) - "3 weeks ago" (past 3 weeks) - "in 3 weeks" (future 3 weeks) - "2 months ago" (past 10 weeks) - "in 2 months" (future 10 weeks) - "10 months ago" (past 10 months) - "in 10 months" (future 10 months) - "1 month ago" (past 1 months) - "in 1 month" (future 1 months) - "3 years ago" (past 3 years) - "in 3 years" (future 3 years) - "1 year ago" (past 1 years) - "in 1 year" (future 1 years) - "3 decades ago" (past 30 years) - "in 3 decades" (future 30 years) - "1 decade ago" (past 10 years) - "in 1 decade" (future 10 years) - "3 centuries ago" (past (* 3 100) years) - "in 3 centuries" (future (* 3 100) years) - "3 millennia ago" (past (* 3 1000) years) - "1 millenium ago" (past 1000 years) - "in 3 millennia" (future (* 3 1000) years) - "in 1 millenium" (future 1000 years))))) + (let [t1-str "2022-01-01T01:00:00" + t1 (jt.ldt/parse t1-str)] + (is (= "a moment ago" + (datetime (jt.ldt/now))) + ":now-dt is optional") + (testing "datetime accepts joda-time values" + (is (= "a moment ago" + (datetime (jt.ldt/now) + :now-dt (jt.ldt/now)))) + (is (= "10 minutes ago" + (datetime (jt.ldt/now) + :now-dt (jt.ldt/plus-minutes (jt.ldt/now) 10))))) + (testing "test phrases" + (doseq [[phrase time-shift-fn] datetime-test-phrases] + (is (= phrase + (datetime (time-shift-fn t1) + :now-dt t1))))) + (testing "suffix and prefix" + (is (= "10 minutes ago" + (datetime (jt.ldt/minus-minutes t1 10) + :prefix "foo" + :now-dt t1)) + "prefix for a time in the past does nothing") + (is (= "10 minutes in the glorious past" + (datetime (jt.ldt/minus-minutes t1 10) + :suffix "in the glorious past" + :now-dt t1))) + (is (= "forward, into our bright future 1 year" + (datetime (jt.ldt/plus-years t1 1) + :now-dt t1 + :prefix "forward, into our bright future"))) + (is (= "in 1 year" + (datetime (jt.ldt/plus-years t1 1) + :now-dt t1 + :suffix "foo")) + "suffix for a time in the past does nothing")))) (deftest durations (testing "duration to terms" @@ -238,4 +276,4 @@ 999 {:short-text "just now"} "just now" 10805000 {:number-format str} "3 hours, 5 seconds" 510805000 {:number-format str - :list-format oxford} "5 days, 21 hours, 53 minutes, and 25 seconds"))) + :list-format oxford} "5 days, 21 hours, 53 minutes, and 25 seconds"))) diff --git a/test/clj_commons/inflect_test.cljc b/test/clj_commons/inflect_test.cljc index 4fe82d7..9811885 100644 --- a/test/clj_commons/inflect_test.cljc +++ b/test/clj_commons/inflect_test.cljc @@ -1,49 +1,49 @@ (ns clj-commons.inflect-test - (:require #?(:clj [clojure.test :refer :all] + (:require #?(:clj [clojure.test :refer [deftest testing is are]] :cljs [cljs.test :refer-macros [deftest testing is are]]) [clj-commons.humanize.inflect :refer [pluralize-noun]])) (deftest pluralize-noun-test - (testing "A count of one returns the standard value" + (testing "a count of one returns the standard value" (are [noun] (= noun (pluralize-noun 1 noun)) "kiss" "robot" "ox")) - (testing "Zero is considered plural" + (testing "zero is considered plural" (are [noun expected-noun] (= expected-noun (pluralize-noun 0 noun)) "kiss" "kisses" "robot" "robots" "ox" "oxen")) - (testing "Testing nouns ending in a sibilant sound." + (testing "nouns ending in a sibilant sound" (is (= (pluralize-noun 2 "kiss") "kisses")) (is (= (pluralize-noun 2 "phase") "phases")) (is (= (pluralize-noun 2 "dish") "dishes")) (is (= (pluralize-noun 2 "witch") "witches"))) - (testing "Testing Nouns ending in y." + (testing "nouns ending in y" (is (= (pluralize-noun 2 "boy") "boys")) (is (= (pluralize-noun 2 "holiday") "holidays")) (is (= (pluralize-noun 2 "party") "parties")) (is (= (pluralize-noun 2 "nanny") "nannies"))) - (testing "Testing nounse ending in F o FE" + (testing "nouns ending in F o FE" (is (= (pluralize-noun 2 "life") "lives")) (is (= (pluralize-noun 2 "thief") "thieves")) (is (= (pluralize-noun 2 "chief") "chiefs")) (is (= (pluralize-noun 2 "roof") "roofs")) (is (= (pluralize-noun 2 "staff") "staffs"))) - (testing "Testing general nouns." + (testing "general nouns" (is (= (pluralize-noun 2 "car") "cars")) (is (= (pluralize-noun 2 "house") "houses")) (is (= (pluralize-noun 2 "book") "books")) (is (= (pluralize-noun 2 "bird") "birds")) (is (= (pluralize-noun 2 "pencil") "pencils"))) - (testing "Testing irregulars nouns" + (testing "irregulars nouns" (are [noun expected-noun] (= expected-noun (pluralize-noun 2 noun)) "ox" "oxen" "moose" "moose"