├── .github ├── CODEOWNERS └── workflows │ └── clojure.yml ├── .gitignore ├── CHANGES.md ├── LICENSE ├── ORIGINATOR ├── README.md ├── VERSION.txt ├── build.clj ├── deps.edn ├── src └── clj_commons │ ├── humanize.cljc │ └── humanize │ ├── inflect.cljc │ ├── time_convert.cljc │ └── time_convert │ └── jvm.cljc └── test └── clj_commons ├── humanize_test.cljc └── inflect_test.cljc /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @hlship 2 | -------------------------------------------------------------------------------- /.github/workflows/clojure.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Java 21 | uses: actions/setup-java@v4 22 | with: 23 | java-version: '11' 24 | distribution: 'corretto' 25 | 26 | - name: Install clojure tools 27 | uses: DeLaGuardo/setup-clojure@12.6 28 | with: 29 | cli: 1.12.0.1479 30 | 31 | - name: Cache clojure dependencies 32 | uses: actions/cache@v4 33 | with: 34 | path: | 35 | ~/.m2/repository 36 | ~/.gitlibs 37 | ~/.deps.clj 38 | # List all files containing dependencies: 39 | key: cljdeps-${{ hashFiles('deps.edn') }} 40 | 41 | - name: Linter 42 | run: clojure -M:clj-kondo --lint src 43 | 44 | - name: Install karma 45 | run: npm install -g karma karma-cljs-test karma-chrome-launcher karma-firefox-launcher 46 | 47 | - name: Run JVM tests 48 | run: clojure -X:test 49 | 50 | - name: Run ClojureScript tests (Chrome) 51 | run: clojure -M:cljs-test -x chrome-headless 52 | 53 | - name: Run ClojureScript tests (Firefox) 54 | run: clojure -M:cljs-test -x firefox-headless 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | .idea/* 11 | *.iml 12 | cljs-test-runner-out/ 13 | .cpcache/ 14 | .clj-kondo/.cache 15 | .lsp/.cache 16 | .portal/vs-code.edn 17 | .calva/repl.calva-repl 18 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 1.1 - 12 Nov 2024 2 | 3 | Thanks to Sean Corfield for straightening out build and dependency issues 4 | with this library; this eliminates unnecessary dependencies for JVM and 5 | Babashka builds. 6 | 7 | ### Changes 8 | 9 | * use Java Time in README examples 10 | * removes clj-time as a dependency 11 | * moves js-joda to a :cljs-test only dependency 12 | * kongeor/cljs-test-runner is behind olical/cljs-test-runner so switch to the latter 13 | * update build-tools 14 | * update GH workflow dependencies 15 | * ignore Calva REPL file 16 | * remove unnecessary cljc_test file (both clj and cljs test runners file the tests just fine) 17 | * use clojure.math in cljs and clj 18 | * fix the bad cljs-only test splice 19 | 20 | ## 1.0 - 14 Jul 2023 21 | 22 | This release moves the repository to [clj-commons](https://github.com/clj-commons/humanize); we thank 23 | [Thura Hlaing](https://github.com/trhura)'s efforts in initiating this project. As part of this move, the Maven 24 | artifact coordinates have changed, as well as the namespaces. 25 | 26 | ### Added 27 | - Tools build building and deployment 28 | - Github actions CI 29 | ### Changed 30 | - Switches from [`com.andrewmcveigh/cljs-time`](https://github.com/andrewmcveigh/cljs-time) to [`com.widdindustries/cljc.java-time`](https://github.com/henryw374/cljc.java-time). This is a potentially breaking change. 31 | - Update dependencies to the most recent versions 32 | - Switched build to deps.edn 33 | ### Fixed 34 | - Code cleanup (warnings generated by clj-kondo) 35 | - Eliminate "already refers to" compiler warnings with Clojure 1.11.x 36 | 37 | [Closed Issues](https://github.com/clj-commons/humanize/issues?q=is%3Aclosed+milestone%3A1.0) 38 | 39 | ## 0.2.2 - 15 Oct 2016 40 | 41 | `clj-commons.inflect/datetime` now supports diffs that represent a 42 | time in the future, as well as _centuries_ and _millennia_ diffs. 43 | 44 | Fix pluralizing nouns that end in _ff_. 45 | 46 | 47 | ## 0.2.1 48 | 49 | `clj-commons.inflect/pluralize-noun` now pluralizes a count of zero; previously any count less 50 | than or equal to 1 was considered singular. 51 | 52 | Added `clj-commons.humanize/duration` and `duration-terms` to format a duration, in 53 | milliseconds, as a string. 54 | 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC 2 | LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM 3 | CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 4 | 5 | 1. DEFINITIONS 6 | 7 | "Contribution" means: 8 | 9 | a) in the case of the initial Contributor, the initial code and 10 | documentation distributed under this Agreement, and 11 | 12 | b) in the case of each subsequent Contributor: 13 | 14 | i) changes to the Program, and 15 | 16 | ii) additions to the Program; 17 | 18 | where such changes and/or additions to the Program originate from and are 19 | distributed by that particular Contributor. A Contribution 'originates' from 20 | a Contributor if it was added to the Program by such Contributor itself or 21 | anyone acting on such Contributor's behalf. Contributions do not include 22 | additions to the Program which: (i) are separate modules of software 23 | distributed in conjunction with the Program under their own license 24 | agreement, and (ii) are not derivative works of the Program. 25 | 26 | "Contributor" means any person or entity that distributes the Program. 27 | 28 | "Licensed Patents" mean patent claims licensable by a Contributor which are 29 | necessarily infringed by the use or sale of its Contribution alone or when 30 | combined with the Program. 31 | 32 | "Program" means the Contributions distributed in accordance with this 33 | Agreement. 34 | 35 | "Recipient" means anyone who receives the Program under this Agreement, 36 | including all Contributors. 37 | 38 | 2. GRANT OF RIGHTS 39 | 40 | a) Subject to the terms of this Agreement, each Contributor hereby grants 41 | Recipient a non-exclusive, worldwide, royalty-free copyright license to 42 | reproduce, prepare derivative works of, publicly display, publicly perform, 43 | distribute and sublicense the Contribution of such Contributor, if any, and 44 | such derivative works, in source code and object code form. 45 | 46 | b) Subject to the terms of this Agreement, each Contributor hereby grants 47 | Recipient a non-exclusive, worldwide, royalty-free patent license under 48 | Licensed Patents to make, use, sell, offer to sell, import and otherwise 49 | transfer the Contribution of such Contributor, if any, in source code and 50 | object code form. This patent license shall apply to the combination of the 51 | Contribution and the Program if, at the time the Contribution is added by the 52 | Contributor, such addition of the Contribution causes such combination to be 53 | covered by the Licensed Patents. The patent license shall not apply to any 54 | other combinations which include the Contribution. No hardware per se is 55 | licensed hereunder. 56 | 57 | c) Recipient understands that although each Contributor grants the licenses 58 | to its Contributions set forth herein, no assurances are provided by any 59 | Contributor that the Program does not infringe the patent or other 60 | intellectual property rights of any other entity. Each Contributor disclaims 61 | any liability to Recipient for claims brought by any other entity based on 62 | infringement of intellectual property rights or otherwise. As a condition to 63 | exercising the rights and licenses granted hereunder, each Recipient hereby 64 | assumes sole responsibility to secure any other intellectual property rights 65 | needed, if any. For example, if a third party patent license is required to 66 | allow Recipient to distribute the Program, it is Recipient's responsibility 67 | to acquire that license before distributing the Program. 68 | 69 | d) Each Contributor represents that to its knowledge it has sufficient 70 | copyright rights in its Contribution, if any, to grant the copyright license 71 | set forth in this Agreement. 72 | 73 | 3. REQUIREMENTS 74 | 75 | A Contributor may choose to distribute the Program in object code form under 76 | its own license agreement, provided that: 77 | 78 | a) it complies with the terms and conditions of this Agreement; and 79 | 80 | b) its license agreement: 81 | 82 | i) effectively disclaims on behalf of all Contributors all warranties and 83 | conditions, express and implied, including warranties or conditions of title 84 | and non-infringement, and implied warranties or conditions of merchantability 85 | and fitness for a particular purpose; 86 | 87 | ii) effectively excludes on behalf of all Contributors all liability for 88 | damages, including direct, indirect, special, incidental and consequential 89 | damages, such as lost profits; 90 | 91 | iii) states that any provisions which differ from this Agreement are offered 92 | by that Contributor alone and not by any other party; and 93 | 94 | iv) states that source code for the Program is available from such 95 | Contributor, and informs licensees how to obtain it in a reasonable manner on 96 | or through a medium customarily used for software exchange. 97 | 98 | When the Program is made available in source code form: 99 | 100 | a) it must be made available under this Agreement; and 101 | 102 | b) a copy of this Agreement must be included with each copy of the Program. 103 | 104 | Contributors may not remove or alter any copyright notices contained within 105 | the Program. 106 | 107 | Each Contributor must identify itself as the originator of its Contribution, 108 | if any, in a manner that reasonably allows subsequent Recipients to identify 109 | the originator of the Contribution. 110 | 111 | 4. COMMERCIAL DISTRIBUTION 112 | 113 | Commercial distributors of software may accept certain responsibilities with 114 | respect to end users, business partners and the like. While this license is 115 | intended to facilitate the commercial use of the Program, the Contributor who 116 | includes the Program in a commercial product offering should do so in a 117 | manner which does not create potential liability for other Contributors. 118 | Therefore, if a Contributor includes the Program in a commercial product 119 | offering, such Contributor ("Commercial Contributor") hereby agrees to defend 120 | and indemnify every other Contributor ("Indemnified Contributor") against any 121 | losses, damages and costs (collectively "Losses") arising from claims, 122 | lawsuits and other legal actions brought by a third party against the 123 | Indemnified Contributor to the extent caused by the acts or omissions of such 124 | Commercial Contributor in connection with its distribution of the Program in 125 | a commercial product offering. The obligations in this section do not apply 126 | to any claims or Losses relating to any actual or alleged intellectual 127 | property infringement. In order to qualify, an Indemnified Contributor must: 128 | a) promptly notify the Commercial Contributor in writing of such claim, and 129 | b) allow the Commercial Contributor tocontrol, and cooperate with the 130 | Commercial Contributor in, the defense and any related settlement 131 | negotiations. The Indemnified Contributor may participate in any such claim 132 | at its own expense. 133 | 134 | For example, a Contributor might include the Program in a commercial product 135 | offering, Product X. That Contributor is then a Commercial Contributor. If 136 | that Commercial Contributor then makes performance claims, or offers 137 | warranties related to Product X, those performance claims and warranties are 138 | such Commercial Contributor's responsibility alone. Under this section, the 139 | Commercial Contributor would have to defend claims against the other 140 | Contributors related to those performance claims and warranties, and if a 141 | court requires any other Contributor to pay any damages as a result, the 142 | Commercial Contributor must pay those damages. 143 | 144 | 5. NO WARRANTY 145 | 146 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON 147 | AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER 148 | EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR 149 | CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A 150 | PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the 151 | appropriateness of using and distributing the Program and assumes all risks 152 | associated with its exercise of rights under this Agreement , including but 153 | not limited to the risks and costs of program errors, compliance with 154 | applicable laws, damage to or loss of data, programs or equipment, and 155 | unavailability or interruption of operations. 156 | 157 | 6. DISCLAIMER OF LIABILITY 158 | 159 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY 160 | CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, 161 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION 162 | LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 163 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 164 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 165 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY 166 | OF SUCH DAMAGES. 167 | 168 | 7. GENERAL 169 | 170 | If any provision of this Agreement is invalid or unenforceable under 171 | applicable law, it shall not affect the validity or enforceability of the 172 | remainder of the terms of this Agreement, and without further action by the 173 | parties hereto, such provision shall be reformed to the minimum extent 174 | necessary to make such provision valid and enforceable. 175 | 176 | If Recipient institutes patent litigation against any entity (including a 177 | cross-claim or counterclaim in a lawsuit) alleging that the Program itself 178 | (excluding combinations of the Program with other software or hardware) 179 | infringes such Recipient's patent(s), then such Recipient's rights granted 180 | under Section 2(b) shall terminate as of the date such litigation is filed. 181 | 182 | All Recipient's rights under this Agreement shall terminate if it fails to 183 | comply with any of the material terms or conditions of this Agreement and 184 | does not cure such failure in a reasonable period of time after becoming 185 | aware of such noncompliance. If all Recipient's rights under this Agreement 186 | terminate, Recipient agrees to cease use and distribution of the Program as 187 | soon as reasonably practicable. However, Recipient's obligations under this 188 | Agreement and any licenses granted by Recipient relating to the Program shall 189 | continue and survive. 190 | 191 | Everyone is permitted to copy and distribute copies of this Agreement, but in 192 | order to avoid inconsistency the Agreement is copyrighted and may only be 193 | modified in the following manner. The Agreement Steward reserves the right to 194 | publish new versions (including revisions) of this Agreement from time to 195 | time. No one other than the Agreement Steward has the right to modify this 196 | Agreement. The Eclipse Foundation is the initial Agreement Steward. The 197 | Eclipse Foundation may assign the responsibility to serve as the Agreement 198 | Steward to a suitable separate entity. Each new version of the Agreement will 199 | be given a distinguishing version number. The Program (including 200 | Contributions) may always be distributed subject to the version of the 201 | Agreement under which it was received. In addition, after a new version of 202 | the Agreement is published, Contributor may elect to distribute the Program 203 | (including its Contributions) under the new version. Except as expressly 204 | stated in Sections 2(a) and 2(b) above, Recipient receives no rights or 205 | licenses to the intellectual property of any Contributor under this 206 | Agreement, whether expressly, by implication, estoppel or otherwise. All 207 | rights in the Program not expressly granted under this Agreement are 208 | reserved. 209 | 210 | This Agreement is governed by the laws of the State of Washington and the 211 | intellectual property laws of the United States of America. No party to this 212 | Agreement will bring a legal action under this Agreement more than one year 213 | after the cause of action arose. Each party waives its rights to a jury trial 214 | in any resulting litigation. 215 | -------------------------------------------------------------------------------- /ORIGINATOR: -------------------------------------------------------------------------------- 1 | @trhura 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # org.clj-commons/humanize 3 | 4 | [![Clojars Project](https://img.shields.io/clojars/v/org.clj-commons/humanize.svg)](https://clojars.org/org.clj-commons/humanize) 5 | ![clojure.yml](https://github.com/clj-commons/humanize/actions/workflows/clojure.yml/badge.svg?event=push) 6 | [![cljdoc badge](https://cljdoc.org/badge/org.clj-commons/humanize)](https://cljdoc.org/d/org.clj-commons/humanize) 7 | 8 | A Clojure(script) library to produce human-readable strings for numbers, dates, and more 9 | based on similar libraries in other languages 10 | 11 | ## Usage 12 | 13 | * [numberword](#numberword) 14 | * [intcomma](#intcomma) 15 | * [intword](#intword) 16 | * [ordinal](#ordinal) 17 | * [filesize](#filesize) 18 | * [truncate](#truncate) 19 | * [oxford](#oxford) 20 | * [pluralize-noun](#pluralize-noun) 21 | * [datetime](#datetime) 22 | * [duration](#duration) 23 | 24 | ### numberword 25 | 26 | Takes a number and return a full written string form. For example, 27 | 23237897 will be written as "twenty-three million two hundred and 28 | thirty-seven thousand eight hundred and ninety-seven". 29 | 30 | ```clojure 31 | user> (require '[clj-commons.humanize :as h]) 32 | nil 33 | 34 | user> (h/numberword 3567) 35 | "three thousand five hundred and sixty-seven" 36 | 37 | user> (h/numberword 25223) 38 | "twenty-five thousand two hundred and twenty-three" 39 | 40 | user> (h/numberword 23237897) 41 | "twenty-three million two hundred and thirty-seven thousand eight hundred and ninety-seven" 42 | ``` 43 | 44 | ### intcomma 45 | 46 | Converts an integer to a string containing commas every three digits. 47 | 48 | ```clojure 49 | user> (h/intcomma 1000) 50 | "1,000" 51 | 52 | user> (h/intcomma 10123) 53 | "10,123" 54 | 55 | user> (h/intcomma 10311) 56 | "10,311" 57 | 58 | user> (h/intcomma 1000000) 59 | "1,000,000" 60 | ``` 61 | 62 | ### intword 63 | 64 | Converts a large integer to a friendly text representation. Works best 65 | for numbers over 1 million. For example, 1000000 becomes '1.0 66 | million', 1200000 becomes '1.2 million' and '1200000000' becomes '1.2 67 | billion'. Supports up to decillion (33 digits) and googol (100 68 | digits). 69 | 70 | ```clojure 71 | user> (h/intword 2000000000) 72 | "2.0 billion" 73 | 74 | user> (h/intword 6000000000000) 75 | "6.0 trillion" 76 | 77 | user> (h/intword 3500000000000000000000N) 78 | "3.5 sextillion" 79 | 80 | user> (h/intword 8100000000000000000000000000000000N) 81 | "8.1 decillion" 82 | ``` 83 | 84 | ### ordinal 85 | 86 | Converts an integer to its ordinal as a string. 87 | 88 | ```clojure 89 | user> (h/ordinal 1) 90 | "1st" 91 | 92 | user> (h/ordinal 2) 93 | "2nd" 94 | 95 | user> (h/ordinal 4) 96 | "4th" 97 | 98 | user> (h/ordinal 11) 99 | "11th" 100 | 101 | user> (h/ordinal 111) 102 | "111th" 103 | ``` 104 | 105 | ### filesize 106 | 107 | Format a number of bytes as a human-readable filesize (eg. 10 kB). 108 | By default, decimal suffixes (kB, MB) are used. Passing the :binary option as true 109 | will use binary suffixes (KiB, MiB) are used. 110 | 111 | The :format option gives more control over how the numeric part of the output filesize 112 | is created. 113 | 114 | ```clojure 115 | user> (h/filesize 3000000 :binary false) 116 | "3.0MB" 117 | 118 | user> (h/filesize 3000000000000 :binary false) 119 | "3.0TB" 120 | 121 | user> (h/filesize 3000 :binary true :format "%.2f") 122 | "2.93KiB" 123 | 124 | user> (h/filesize 3000000 :binary true) 125 | "2.9MiB" 126 | ``` 127 | 128 | ### truncate 129 | 130 | Truncate a string with suffix (ellipsis by default) if it is longer 131 | than specified length. 132 | 133 | ```clojure 134 | user> (h/truncate "abcdefghijklmnopqrstuvwxyz" 10) 135 | "abcdefg..." 136 | 137 | user> (h/truncate "abcdefghijklmnopqrstuvwxyz" 10 "[more]") 138 | "abcd[more]" 139 | ``` 140 | 141 | ### oxford 142 | 143 | Converts a list of items to a human-readable string with an optional 144 | limit. 145 | 146 | ```clojure 147 | user> (h/oxford ["apple" "orange" "mango"]) 148 | "apple, orange, and mango" 149 | 150 | user> (h/oxford ["apple" "orange" "mango" "pear"] 151 | :maximum-display 2) 152 | "apple, orange, and 2 others" 153 | 154 | user> (h/oxford ["apple" "orange" "mango" "pear"] 155 | :maximum-display 2 156 | :truncate-noun "fruit") 157 | "apple, orange, and 2 other fruits" 158 | 159 | user> (h/oxford ["apple" "orange" "mango" "pear"] 160 | :maximum-display 2 161 | :number-format h/numberword 162 | :truncate-noun "fruit") 163 | "apple, orange, and two other fruits" 164 | ``` 165 | 166 | ### pluralize-noun 167 | 168 | Return the pluralized noun if the given number is not 1. 169 | 170 | ```clojure 171 | user (require '[clj-commons.humanize.inflect :as i]) 172 | nil 173 | 174 | user> (i/pluralize-noun 2 "thief") 175 | "thieves" 176 | 177 | user> (i/pluralize-noun 3 "tomato") 178 | "tomatoes" 179 | 180 | user> (i/pluralize-noun 4 "roof") 181 | "roofs" 182 | 183 | user> (i/pluralize-noun 5 "person") 184 | "people" 185 | 186 | user> (i/pluralize-noun 6 "buzz") 187 | "buzzes" 188 | ``` 189 | 190 | Other functions in the inflect namespace are used to extend the rules 191 | for how particular words, or particular letter patterns in words, 192 | can be pluralized. 193 | 194 | ### datetime 195 | 196 | Given a datetime or date, return a human-friendly representation 197 | of the amount of time difference, relative to the current time. 198 | 199 | ```clojure 200 | user> (import '(java.time LocalDateTime) '(java.time.temporal ChronoUnit)) 201 | java.time.temporal.ChronoUnit 202 | 203 | user> (h/datetime (.plusSeconds (LocalDateTime/now) -30)) 204 | "30 seconds ago" 205 | 206 | user> (h/datetime (.plusSeconds (LocalDateTime/now) 30)) 207 | "in 30 seconds" 208 | 209 | user> (h/datetime (.plus (LocalDateTime/now) -20 ChronoUnit/YEARS)) 210 | "2 decades ago" 211 | 212 | user> (h/datetime (.plus (LocalDateTime/now) -7 ChronoUnit/YEARS)) 213 | "7 years ago" 214 | 215 | ``` 216 | 217 | ### duration 218 | 219 | Given a duration in milliseconds, return a human-friendly 220 | representation of the amount of time passed. 221 | 222 | ```clojure 223 | user> (h/duration 2000) 224 | "two seconds" 225 | 226 | user> (h/duration 325100) 227 | "five minutes, twenty-five seconds" 228 | 229 | user> (h/duration 500) 230 | "less than a second" 231 | 232 | user> (h/duration 325100 {:number-format str}) 233 | => "5 minutes, 25 seconds" 234 | 235 | ``` 236 | 237 | ## Linting 238 | 239 | Run: 240 | 241 | ```sh 242 | clj -M:clj-kondo --lint src 243 | ``` 244 | 245 | ## Running Tests 246 | 247 | JVM tests can be run with just: 248 | 249 | ``` 250 | clojure -X:test 251 | ``` 252 | 253 | For cljs, you will need node/npm in order to install karma: 254 | 255 | ```sh 256 | npm install -g karma karma-cljs-test karma-chrome-launcher karma-firefox-launcher 257 | ``` 258 | 259 | Then tests can be run with: 260 | 261 | ```clj 262 | clojure -M:cljs-test -x chrome-headless 263 | ``` 264 | 265 | Or `-x firefox-headless`. 266 | 267 | ## Deployment 268 | 269 | Check [deps-deploy README](https://github.com/slipset/deps-deploy) for details regarding clojars credentials. 270 | 271 | Build a snapshot jar: 272 | 273 | ```clj 274 | clojure -T:build jar 275 | ``` 276 | 277 | Deploy a snapshot: 278 | 279 | ```clj 280 | clojure -T:build deploy 281 | ``` 282 | 283 | Set `:release` to `true` for a release version (make sure the version number in `build.clj` is correct first): 284 | 285 | ```clj 286 | clojure -T:build deploy :release true 287 | ``` 288 | 289 | ## TODO 290 | 291 | + Add other missing functions 292 | * [JS-humanize](https://github.com/milanvrekic/JS-humanize) 293 | * [coffee-humanize](https://github.com/HubSpot/humanize/) 294 | 295 | 296 | ## License 297 | 298 | Copyright 2015-2023 Thura Hlaing 299 | 300 | Distributed under the Eclipse Public License either version 1.0 or (at 301 | your option) any later version. 302 | -------------------------------------------------------------------------------- /VERSION.txt: -------------------------------------------------------------------------------- 1 | 1.1 2 | -------------------------------------------------------------------------------- /build.clj: -------------------------------------------------------------------------------- 1 | ;; clj -T:build 2 | 3 | (ns build 4 | (:require [clojure.tools.build.api :as build] 5 | [net.lewisship.build :as b] 6 | [clojure.string :as str])) 7 | 8 | (def lib 'org.clj-commons/humanize) 9 | (def version (-> "VERSION.txt" slurp str/trim)) 10 | 11 | (def jar-params {:project-name lib 12 | :version version}) 13 | 14 | (defn clean 15 | [_params] 16 | (build/delete {:path "target"})) 17 | 18 | (defn jar 19 | [_params] 20 | (b/create-jar jar-params)) 21 | 22 | (defn deploy 23 | [_params] 24 | (clean nil) 25 | (b/deploy-jar (assoc (jar nil) :sign-artifacts? false))) 26 | 27 | (defn codox 28 | [_params] 29 | (b/generate-codox {:project-name lib 30 | :version version})) 31 | 32 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {org.clojure/clojure {:mvn/version "1.11.4"} 2 | com.widdindustries/cljc.java-time {:mvn/version "0.1.21"}} 3 | :paths ["src"] 4 | 5 | :net.lewisship.build/scm 6 | {:url "https://github.com/clj-commons/humanize" 7 | :license :epl} 8 | 9 | :aliases 10 | ;; clj -X:test 11 | {:test {:extra-paths ["test"] 12 | :extra-deps {io.github.cognitect-labs/test-runner 13 | {:git/tag "v0.5.1" :git/sha "dfb30dd"}} 14 | :exec-fn cognitect.test-runner.api/test} 15 | :cljs-test {:extra-paths ["test"] 16 | :extra-deps {olical/cljs-test-runner {:mvn/version "3.8.1"} 17 | henryw374/js-joda {:mvn/version "3.2.0-0"}} 18 | :main-opts ["-m" "cljs-test-runner.main"]} 19 | :clj-kondo {:replace-deps {clj-kondo/clj-kondo {:mvn/version "RELEASE"}} 20 | :main-opts ["-m" "clj-kondo.main"]} 21 | :build {:deps {io.github.hlship/build-tools 22 | {:git/tag "0.10.2" :git/sha "3c446e4"}} 23 | :ns-default build}}} 24 | -------------------------------------------------------------------------------- /src/clj_commons/humanize.cljc: -------------------------------------------------------------------------------- 1 | (ns clj-commons.humanize 2 | (:refer-clojure :exclude [abs]) 3 | (:require #?(:clj [clojure.math :as math :refer [floor round log log10]]) 4 | [clj-commons.humanize.inflect :refer [pluralize-noun in?]] 5 | [clj-commons.humanize.time-convert :refer [coerce-to-local-date-time]] 6 | [cljc.java-time.duration :as jt.duration] 7 | [cljc.java-time.local-date-time :as jt.ldt] 8 | [clojure.string :as string :refer [join]] 9 | #?@(:cljs [[goog.string :as gstring] 10 | [goog.string.format]]))) 11 | 12 | #?(:clj (def ^:private num-format format) 13 | :cljs (def ^:private num-format #(gstring/format %1 %2))) 14 | 15 | #?(:clj (def ^:private expt math/pow) 16 | :cljs (def ^:private expt (.-pow js/Math))) 17 | 18 | #?(:cljs (def ^:private floor (.-floor js/Math))) 19 | #?(:cljs (def ^:private round (.-round js/Math))) 20 | #?(:clj (def ^:private abs clojure.core/abs) 21 | :cljs (def ^:private abs (.-abs js/Math))) 22 | 23 | #?(:cljs (def ^:private log (.-log js/Math))) 24 | 25 | #?(:cljs (def ^:private rounding-const 1000000)) 26 | 27 | #?(:cljs (def ^:private log10 (or (.-log10 js/Math) ;; prefer native implementation 28 | #(/ (.round js/Math 29 | (* rounding-const 30 | (/ (.log js/Math %) 31 | js/Math.LN10))) 32 | rounding-const)))) ;; TODO: improve rounding here 33 | 34 | #?(:clj (def ^:private char->int #(Character/getNumericValue %)) 35 | :cljs (def ^:private char->int #(int %))) 36 | 37 | (defn intcomma 38 | "Converts an integer to a string containing commas. every three digits. 39 | For example, 3000 becomes '3,000' and 45000 becomes '45,000'. " 40 | [num] 41 | (let [decimal (abs (int num)) ;; FIXME: (abs ) 42 | sign (if (< num 0) "-" "") 43 | 44 | ;; convert into string representation 45 | repr (str decimal) 46 | repr-len (count repr) 47 | 48 | ;; right-aligned 3 elements partition 49 | partitioned [(subs repr 0 (rem repr-len 3)) 50 | (map #(apply str %) 51 | (partition 3 (subs repr 52 | (rem repr-len 3))))] 53 | 54 | ;; flatten, and remove empty string 55 | partitioned (remove empty? (flatten partitioned))] 56 | 57 | (apply str sign (interpose "," partitioned)))) 58 | 59 | 60 | (defn ordinal 61 | "Converts an integer to its ordinal as a string. 1 is '1st', 2 is '2nd', 62 | 3 is '3rd', etc." 63 | [num] 64 | (let [ordinals ["th", "st", "nd", "rd", "th", 65 | "th", "th", "th", "th", "th"] 66 | remainder-100 (rem num 100) 67 | remainder-10 (rem num 10)] 68 | 69 | (if (in? remainder-100 [11 12 13]) 70 | ;; special case for *11, *12, *13 71 | (str num (ordinals 0)) 72 | (str num (ordinals remainder-10))))) 73 | 74 | (defn- logn [num base] 75 | (/ (round (log num)) 76 | (round (log base)))) 77 | 78 | 79 | (def ^:private human-pows [[100 " googol"] 80 | [33 " decillion"] 81 | [30 " nonillion"] 82 | [27 " octillion"] 83 | [24 " septillion"] 84 | [21 " sextillion"] 85 | [18 " quintillion"] 86 | [15 " quadrillion"] 87 | [12 " trillion"] 88 | [9 " billion"] 89 | [6 " million"] 90 | [0 ""]]) 91 | 92 | (defn intword 93 | "Converts a large integer to a friendly text representation. Works best for 94 | numbers over 1 million. For example, 1000000 becomes '1.0 million', 1200000 95 | becomes '1.2 million' and '1200000000' becomes '1.2 billion'. Supports up to 96 | decillion (33 digits) and googol (100 digits)." 97 | [num & {:keys [format] :or {format "%.1f"}}] 98 | (let [base-pow (int (floor (log10 num))) 99 | [base-pow suffix] (first (filter (fn [[base _]] (>= base-pow base)) human-pows)) 100 | value (float (/ num (expt 10 base-pow)))] 101 | (str (num-format format value) suffix))) 102 | 103 | (def ^:private numap 104 | {0 "" 105 | 1 "one" 106 | 2 "two" 107 | 3 "three" 108 | 4 "four" 109 | 5 "five" 110 | 6 "six" 111 | 7 "seven" 112 | 8 "eight" 113 | 9 "nine" 114 | 10 "ten" 115 | 11 "eleven" 116 | 12 "twelve" 117 | 13 "thirteen" 118 | 14 "fourteen" 119 | 15 "fifteen" 120 | 16 "sixteen" 121 | 17 "seventeen" 122 | 18 "eighteen" 123 | 19 "nineteen" 124 | 20 "twenty" 125 | 30 "thirty" 126 | 40 "forty" 127 | 50 "fifty" 128 | 60 "sixty" 129 | 70 "seventy" 130 | 80 "eighty" 131 | 90 "ninety"}) 132 | 133 | (defn numberword 134 | "Takes a number and return a full written string form. For example, 135 | 23237897 will be written as \"twenty-three million two hundred and 136 | thirty-seven thousand eight hundred and ninety-seven\". " 137 | [num] 138 | 139 | ;; special case for zero 140 | (if (zero? num) 141 | "zero" 142 | 143 | (let [digitcnt (int (log10 num)) 144 | divisible? (fn [num div] (zero? (rem num div))) 145 | n-digit (fn [num n] (char->int (.charAt (str num) n)))] ;; TODO rename 146 | 147 | (cond 148 | ;; handle million part 149 | (>= digitcnt 6) (if (divisible? num 1000000) 150 | (join " " [(numberword (int (/ num 1000000))) 151 | "million"]) 152 | (join " " [(numberword (int (/ num 1000000))) 153 | "million" 154 | (numberword (rem num 1000000))])) 155 | 156 | ;; handle thousand part 157 | (>= digitcnt 3) (if (divisible? num 1000) 158 | (join " " [(numberword (int (/ num 1000))) 159 | "thousand"]) 160 | (join " " [(numberword (int (/ num 1000))) 161 | "thousand" 162 | (numberword (rem num 1000))])) 163 | 164 | ;; handle hundred part 165 | (>= digitcnt 2) (if (divisible? num 100) 166 | (join " " [(numap (int (/ num 100))) 167 | "hundred"]) 168 | (join " " [(numap (int (/ num 100))) 169 | "hundred" 170 | "and" 171 | (numberword (rem num 100))])) 172 | 173 | ;; handle the last two digits 174 | (< num 20) (numap num) 175 | (divisible? num 10) (numap num) 176 | :else (join "-" [(numap (* 10 (n-digit num 0))) 177 | (numap (n-digit num 1))]))))) 178 | 179 | (def ^:private decimal-sizes [:B :KB :MB :GB :TB :PB :EB :ZB :YB]) 180 | 181 | (def ^:private binary-sizes [:B :KiB :MiB :GiB :TiB :PiB :EiB :ZiB :YiB]) 182 | 183 | (defn filesize 184 | "Format a number of bytes as a human readable filesize (eg. 10 kB). By 185 | default, decimal suffixes (kB, MB) are used. Passing :binary true will use 186 | binary suffixes (KiB, MiB) instead." 187 | [bytes & {:keys [binary format] 188 | :or {binary false 189 | format "%.1f"}}] 190 | 191 | (if (zero? bytes) 192 | ;; special case for zero 193 | "0" 194 | 195 | (let [units (if binary binary-sizes decimal-sizes) 196 | base (if binary 1024 1000) 197 | 198 | base-pow (int (floor (logn bytes base))) 199 | ;; if base power shouldn't be larger than biggest unit 200 | base-pow (if (< base-pow (count units)) 201 | base-pow 202 | (dec (count units))) 203 | suffix (name (get units base-pow)) 204 | value (float (/ bytes (expt base base-pow)))] 205 | (str (num-format format value) suffix)))) 206 | 207 | (defn truncate 208 | "Truncate a string with suffix (ellipsis by default) if it is 209 | longer than specified length." 210 | 211 | ([string length suffix] 212 | (let [string-len (count string) 213 | suffix-len (count suffix)] 214 | 215 | (if (<= string-len length) 216 | string 217 | (str (subs string 0 (- length suffix-len)) suffix)))) 218 | 219 | ([string length] 220 | (truncate string length "…"))) 221 | 222 | (defn oxford 223 | "Converts a list of items to a human-readable string, such as \"apple, pear, and 2 other fruits\". 224 | 225 | Options: 226 | :maximum-display - the maximum number of items to display before identifying the remaining count (default: 4) 227 | :truncate-noun - the string used to identify the type of items in the list, e.g., \"fruit\" - will 228 | be pluralized if necessary 229 | :number-format - function used to format the number of additional items in the list (default: `str`) 230 | " 231 | [coll & {:keys [maximum-display truncate-noun number-format] 232 | :or {maximum-display 4 233 | number-format str}}] 234 | 235 | (let [coll-length (count coll)] 236 | (cond 237 | ;; if coll has one or zero items 238 | (< coll-length 2) (join coll) 239 | 240 | ;; if coll has exactly two items, there won't be a comma, so join them with "and" 241 | (and (= coll-length 2) 242 | (<= coll-length maximum-display)) 243 | (str (first coll) " and " (second coll)) 244 | 245 | ;; if the number of items doesn't exceed maximum display size 246 | (<= coll-length maximum-display) (let [before-last (take (dec coll-length) coll) 247 | last-item (last coll)] 248 | (str (join (interpose ", " before-last)) 249 | ", and " last-item)) 250 | 251 | (> coll-length maximum-display) (let [display-coll (take maximum-display coll) 252 | remaining (- coll-length maximum-display) 253 | remaining' (number-format remaining) 254 | last-item (if (string/blank? truncate-noun) 255 | (str remaining' " " (pluralize-noun remaining "other")) 256 | (str remaining' " other " (pluralize-noun remaining 257 | truncate-noun)))] 258 | (str 259 | (join (interpose ", " display-coll)) 260 | ; if only one item is displayed there should be no oxford comma 261 | (when-not (= 1 maximum-display) 262 | ",") 263 | " and " last-item)) 264 | ;; TODO: shouldn't reach here, throw exception 265 | :else coll-length))) 266 | 267 | (def ^:private one-minute-in-seconds 60) 268 | (def ^:private one-hour-in-seconds (* 60 one-minute-in-seconds)) 269 | (def ^:private one-day-in-seconds (* 24 one-hour-in-seconds)) 270 | (def ^:private one-week-in-seconds (* 7 one-day-in-seconds)) 271 | (def ^:private one-month-in-seconds (* 4 one-week-in-seconds)) 272 | (def ^:private one-year-in-seconds (* 52 one-week-in-seconds)) 273 | (def ^:private one-decade-in-seconds (* 10 one-year-in-seconds)) 274 | (def ^:private one-century-in-seconds (* 100 one-year-in-seconds)) 275 | (def ^:private one-millennia-in-seconds (* 1000 one-year-in-seconds)) 276 | 277 | (defn format-delta-str 278 | [amount time-unit suffix prefix future-time?] 279 | (if future-time? 280 | (str prefix " " amount " " (pluralize-noun amount time-unit)) 281 | (str amount " " (pluralize-noun amount time-unit) " " suffix))) 282 | 283 | (defn datetime 284 | "Given a java.time.LocalDate or java.time.LocalDateTime, returns a 285 | human-friendly representation of the amount of time elapsed compared to now. 286 | 287 | Optional keyword args: 288 | * :now-dt - specify the value for 'now' 289 | * :prefix - adjust the verbiage for times in the future 290 | * :suffix - adjust the verbiage for times in the past" 291 | [then-dt & {:keys [now-dt suffix prefix] 292 | :or {now-dt (jt.ldt/now) 293 | suffix "ago" 294 | prefix "in"}}] 295 | (let [then-dt (coerce-to-local-date-time then-dt) 296 | now-dt (coerce-to-local-date-time now-dt) 297 | future-time? (jt.ldt/is-after then-dt now-dt) 298 | ;; get the Duration between the two times 299 | time-between (-> (jt.duration/between then-dt now-dt) 300 | (jt.duration/abs)) 301 | delta-in-seconds (jt.duration/get-seconds time-between) 302 | delta-in-minutes (int (/ delta-in-seconds one-minute-in-seconds)) 303 | delta-in-hours (int (/ delta-in-seconds one-hour-in-seconds)) 304 | delta-in-days (int (/ delta-in-seconds one-day-in-seconds)) 305 | delta-in-weeks (int (/ delta-in-seconds one-week-in-seconds)) 306 | delta-in-months (int (/ delta-in-seconds one-month-in-seconds)) 307 | delta-in-years (int (/ delta-in-seconds one-year-in-seconds)) 308 | delta-in-decades (int (/ delta-in-seconds one-decade-in-seconds)) 309 | delta-in-centuries (int (/ delta-in-seconds one-century-in-seconds)) 310 | delta-in-millennia (int (/ delta-in-seconds one-millennia-in-seconds))] 311 | (cond 312 | (pos? delta-in-millennia) 313 | (format-delta-str delta-in-millennia "millenium" suffix prefix future-time?) 314 | 315 | (pos? delta-in-centuries) 316 | (format-delta-str delta-in-centuries "century" suffix prefix future-time?) 317 | 318 | (pos? delta-in-decades) 319 | (format-delta-str delta-in-decades "decade" suffix prefix future-time?) 320 | 321 | (pos? delta-in-years) 322 | (format-delta-str delta-in-years "year" suffix prefix future-time?) 323 | 324 | (pos? delta-in-months) 325 | (format-delta-str delta-in-months "month" suffix prefix future-time?) 326 | 327 | (pos? delta-in-weeks) 328 | (format-delta-str delta-in-weeks "week" suffix prefix future-time?) 329 | 330 | (pos? delta-in-days) 331 | (format-delta-str delta-in-days "day" suffix prefix future-time?) 332 | 333 | (pos? delta-in-hours) 334 | (format-delta-str delta-in-hours "hour" suffix prefix future-time?) 335 | 336 | (pos? delta-in-minutes) 337 | (format-delta-str delta-in-minutes "minute" suffix prefix future-time?) 338 | 339 | (pos? delta-in-seconds) 340 | (format-delta-str delta-in-seconds "second" suffix prefix future-time?) 341 | 342 | future-time? 343 | (str prefix " a moment") 344 | 345 | :else 346 | (str "a moment " suffix)))) 347 | 348 | (def ^:private duration-periods 349 | [[(* 1000 60 60 24 365) "year"] 350 | [(* 1000 60 60 24 31) "month"] 351 | [(* 1000 60 60 24 7) "week"] 352 | [(* 1000 60 60 24) "day"] 353 | [(* 1000 60 60) "hour"] 354 | [(* 1000 60) "minute"] 355 | [1000 "second"]]) 356 | 357 | (defn- duration-terms 358 | "Converts a duration, in milliseconds, to a set of terms describing the duration. 359 | The terms are in descending order, largest period to smallest. 360 | 361 | Each term is a tuple of count and period name, e.g., `[5 \"second\"]`. 362 | 363 | After seconds are accounted for, remaining milliseconds are ignored." 364 | [duration-ms] 365 | {:pre [(<= 0 duration-ms)]} 366 | (loop [remainder duration-ms 367 | [[period-ms period-name] & more-periods] duration-periods 368 | terms []] 369 | (cond 370 | (nil? period-ms) 371 | terms 372 | 373 | (< remainder period-ms) 374 | (recur remainder more-periods terms) 375 | 376 | :else 377 | (let [period-count (int (/ remainder period-ms)) 378 | next-remainder (mod remainder period-ms)] 379 | (recur next-remainder more-periods 380 | (conj terms [period-count period-name])))))) 381 | 382 | (defn duration 383 | "Converts duration, in milliseconds, into a string describing it in terms 384 | of years, months, weeks, days, hours, minutes, and seconds. 385 | 386 | Ex: 387 | 388 | (duration 325100) => \"five minutes, twenty-five seconds\" 389 | 390 | The months and years periods are not based on actual calendar, so are approximate; this 391 | function works best for shorter periods of time. 392 | 393 | The optional options map allow some control over the result. 394 | 395 | :list-format (default: a function) can be set to a function such as oxford 396 | 397 | :number-format (default: numberword) function used to format period counts 398 | 399 | :short-text (default: \"less than a second\") " 400 | {:added "0.2.1"} 401 | ([duration-ms] 402 | (duration duration-ms nil)) 403 | ([duration-ms options] 404 | (let [terms (duration-terms duration-ms) 405 | {:keys [number-format list-format short-text] 406 | :or {number-format numberword 407 | short-text "less than a second" 408 | ;; This default, instead of oxford, because the entire string is a single "value" 409 | list-format #(join ", " %)}} options] 410 | (if (seq terms) 411 | (->> terms 412 | (map (fn [[period-count period-name]] 413 | (str (number-format period-count) 414 | " " 415 | (pluralize-noun period-count period-name)))) 416 | list-format) 417 | short-text)))) 418 | -------------------------------------------------------------------------------- /src/clj_commons/humanize/inflect.cljc: -------------------------------------------------------------------------------- 1 | (ns clj-commons.humanize.inflect 2 | "Functions and rules for pluralizing nouns." 3 | (:require [clojure.string :refer [ends-with?]])) 4 | 5 | (defn in? 6 | "Return true if x is in coll, else false." 7 | [x coll] 8 | ;; FIXME: duplicate 9 | (some #(= x %) coll)) 10 | 11 | (def ^:private pluralize-noun-rules (atom [])) 12 | (def ^:private pluralize-noun-exceptions (atom {})) 13 | 14 | (defn pluralize-noun 15 | "Return the pluralized noun if the `count' is not 1." 16 | [count noun] 17 | {:pre [(<= 0 count)]} 18 | (let [singular? (== count 1)] 19 | (if singular? 20 | noun ; If singular, return noun 21 | (some (fn [[cond? result-fn]] 22 | (when (cond? noun) 23 | (result-fn noun))) 24 | @pluralize-noun-rules)))) 25 | 26 | (defn add-pluralize-noun-rule 27 | "Adds a rule for pluralizing. The singular form of the noun is passed to the cond? 28 | predicate and if that return a truthy value, the singular form is passed 29 | to the result-fn to generate the plural form. 30 | 31 | The rule description is for documentation only, it is ignored and may be nil." 32 | [_rule-description cond? result-fn] 33 | (swap! pluralize-noun-rules 34 | conj 35 | [cond? result-fn])) 36 | 37 | (defn add-pluralize-noun-exceptions 38 | "Adds some number of exception cases. 39 | 40 | exceptions is a map from singular form to plural form. 41 | 42 | The exception description is for documentation only, it is ignored and may be nil." 43 | [_exception-description exceptions] 44 | (swap! pluralize-noun-exceptions into exceptions)) 45 | 46 | ;; the order of rules is important 47 | (add-pluralize-noun-rule "For irregular nouns, use the exceptions." 48 | (fn [noun] (contains? @pluralize-noun-exceptions noun)) 49 | (fn [noun] (@pluralize-noun-exceptions noun))) 50 | 51 | (add-pluralize-noun-rule "For nouns ending within consonant + y, suffixes `ies' " 52 | (fn [noun] (and (ends-with? noun "y") 53 | (not (boolean (in? (-> noun butlast last) ;; before-last char 54 | [\a \e \i \o \u]))))) 55 | (fn [noun] (str (-> noun butlast clojure.string/join) "ies"))) 56 | 57 | (add-pluralize-noun-rule "For nouns ending with ss, x, z, ch or sh, suffixes `es.'" 58 | (fn [noun] (some #(ends-with? noun %) 59 | ["ss" "x" "z" "ch" "sh"])) 60 | (fn [noun] (str noun "es"))) 61 | 62 | (add-pluralize-noun-rule "For nouns ending with `f', suffixes `ves'" 63 | (fn [noun] (and (ends-with? noun "f") 64 | (not (ends-with? noun "ff")))) 65 | (fn [noun] (str (-> noun butlast clojure.string/join) "ves"))) 66 | 67 | (add-pluralize-noun-rule "For nouns ending with `fe', suffixes `ves'" 68 | (fn [noun] (ends-with? noun "fe")) 69 | (fn [noun] (str (-> noun butlast butlast clojure.string/join) "ves"))) 70 | 71 | (add-pluralize-noun-rule "Always append `s' at the end of noun." 72 | (fn [_noun] true) ;; always return true 73 | (fn [noun] (str noun "s"))) 74 | 75 | (add-pluralize-noun-exceptions "Irregular nouns ending in en" 76 | { 77 | "ox" "oxen", 78 | "child" "children", 79 | "man" "men", 80 | "woman" "women", 81 | "foot" "feet", 82 | "tooth" "teeth", 83 | "goose" "geese", 84 | "mouse" "mice" , 85 | "person" "people", 86 | "louse" "lice", 87 | }) 88 | 89 | 90 | (add-pluralize-noun-exceptions "Irregular nouns ending in f" 91 | { 92 | "chef" "chefs", 93 | "cliff" "cliffs", 94 | "ref" "refs", 95 | "roof" "roofs", 96 | "chief" "chiefs", 97 | } 98 | ) 99 | 100 | (add-pluralize-noun-exceptions "Irregular nouns ending in o-es" 101 | { 102 | "negro" "negroes", 103 | "buffalo" "buffaloes", 104 | "flamingo" "flamingoes", 105 | "hero" "heroes", 106 | "mango" "mangoes", 107 | "mosquito" "mosquitoes", 108 | "potato" "potatoes", 109 | "tomato" "tomatoes", 110 | "tornado" "tornadoes", 111 | "torpedo" "torpedoes", 112 | "tuxedo" "tuxedoes", 113 | "volcano" "volcanoes", 114 | "zero" "zeroes", 115 | "echo" "echoes", 116 | "banjo" "banjoes", 117 | "cactus" "cactuses" 118 | } 119 | ) 120 | 121 | (add-pluralize-noun-exceptions "Nouns with identical singular and plural forms." 122 | { 123 | "bison" "bison", 124 | "buffalo" "buffalo", 125 | "deer" "deer", 126 | "duck" "duck", 127 | "fish" "fish", 128 | "moose" "moose", 129 | "pike" "pike", 130 | "sheep" "sheep", 131 | "salmon" "salmon", 132 | "trout" "trout", 133 | "swine" "swine", 134 | "plankton" "plankton", 135 | "squid" "squid", 136 | }) 137 | 138 | (add-pluralize-noun-exceptions "Special cases" 139 | { 140 | "millenium" "millennia", 141 | }) 142 | -------------------------------------------------------------------------------- /src/clj_commons/humanize/time_convert.cljc: -------------------------------------------------------------------------------- 1 | #_:clj-kondo/ignore 2 | (ns ^:no-doc clj-commons.humanize.time-convert 3 | "Internal utility to convert strings and other typs into LocalDateTime " 4 | (:require [cljc.java-time.extn.predicates :as jt.predicates] 5 | [cljc.java-time.format.date-time-formatter :as dt.formats] 6 | [cljc.java-time.local-date-time :as jt.ldt] 7 | [cljc.java-time.instant :as jt.i] 8 | [cljc.java-time.zone-id :as jt.zi] 9 | #?(:clj [clj-commons.humanize.time-convert.jvm :as jvm]))) 10 | 11 | (defn- looks-like-an-iso8601-string? 12 | [s] 13 | (and (string? s) 14 | (boolean (re-matches #"^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d$" s)))) 15 | 16 | (defn- looks-like-a-date-string? 17 | [s] 18 | (and (string? s) 19 | (boolean (re-matches #"^\d\d\d\d-\d\d-\d\d$" s)))) 20 | 21 | (defn coerce-to-local-date-time 22 | "Does its best to convert t into a java.time.LocalDateTime object. 23 | Accepts: 24 | - java.time.LocalDateTime and java.time.LocalDate 25 | - java.util.Date (on the JVM) 26 | - Strings in 'yyyy-MM-dd' and 'yyyy-MM-ddTHH:MM:SS' formats 27 | - js/Date 28 | 29 | Throws an Exception if unable to convert." 30 | [t] 31 | (cond 32 | ;; t is already a java.time.LocalDateTime 33 | (jt.predicates/local-date-time? t) t 34 | 35 | #?@(:clj [(jt.predicates/local-date? t) 36 | (jt.ldt/parse (jvm/java-time-local-date->iso8601-str t) dt.formats/iso-date-time) 37 | 38 | (jvm/java-util-date? t) 39 | (jt.ldt/parse (jvm/java-util-date->iso8601-str t) dt.formats/iso-date-time)] 40 | :cljs [(instance? js/Date t) 41 | (jt.ldt/of-instant (jt.i/of-epoch-milli (.getTime t)) (jt.zi/system-default))]) 42 | 43 | ;; Strings 44 | (looks-like-an-iso8601-string? t) 45 | (jt.ldt/parse t dt.formats/iso-date-time) 46 | 47 | (looks-like-a-date-string? t) 48 | (jt.ldt/parse (str t "T00:00:00") dt.formats/iso-date-time) 49 | 50 | ;; ¯\_(ツ)_/¯ 51 | :else 52 | (throw (ex-info "unable to coerce to java.time.LocalDateTime" 53 | {:value t})))) 54 | -------------------------------------------------------------------------------- /src/clj_commons/humanize/time_convert/jvm.cljc: -------------------------------------------------------------------------------- 1 | (ns ^:no-doc clj-commons.humanize.time-convert.jvm 2 | "Separate out the JVM-only checks and conversions." 3 | (:import (java.util Date) 4 | (java.time LocalDate) 5 | (java.text SimpleDateFormat))) 6 | 7 | (defn java-util-date? [d] 8 | (instance? Date d)) 9 | 10 | (def ^:private java-util-date-iso8601-formatter 11 | (SimpleDateFormat. "yyyy-MM-dd'T'HH:mm:ss")) 12 | 13 | (defn java-util-date->iso8601-str 14 | ^String [^Date date] 15 | (.format java-util-date-iso8601-formatter date)) 16 | 17 | (defn java-time-local-date->iso8601-str 18 | ^String [^LocalDate date] 19 | (str (.toString date) "T00:00:00")) 20 | -------------------------------------------------------------------------------- /test/clj_commons/humanize_test.cljc: -------------------------------------------------------------------------------- 1 | (ns clj-commons.humanize-test 2 | (:require #?(:clj [clojure.test :refer [deftest testing is are]] 3 | :cljs [cljs.test :refer-macros [deftest testing is are]]) 4 | [clj-commons.humanize :refer [intcomma ordinal intword numberword 5 | filesize truncate oxford datetime 6 | duration] 7 | :as h] 8 | [clojure.math :as math] 9 | [cljc.java-time.local-date-time :as jt.ldt])) 10 | 11 | (def ^:private expt math/pow) 12 | 13 | (deftest intcomma-test 14 | (are [input expected] (= expected (intcomma input)) 15 | 100, "100" 16 | 1000 "1,000", 17 | 10123 "10,123" 18 | 10311 "10,311" 19 | 1000000 "1,000,000" 20 | -100 "-100" 21 | -10123 "-10,123" 22 | -10311 "-10,311", 23 | -1000000 "-1,000,000")) 24 | 25 | (deftest ordinal-test 26 | (are [input expected] (= expected (ordinal input)) 27 | 1 "1st" 28 | 2 "2nd" 29 | 3 "3rd" 30 | 4 "4th" 31 | 11 "11th" 32 | 12 "12th" 33 | 13 "13th" 34 | 101 "101st" 35 | 102 "102nd" 36 | 103 "103rd" 37 | 111 "111th")) 38 | 39 | (deftest intword-test 40 | (are [input format expected] (= expected (if format 41 | (intword input :format format) 42 | (intword input))) 43 | 100 nil "100.0" 44 | 1000000 nil "1.0 million" 45 | 1200000 nil "1.2 million" 46 | 1290000 nil "1.3 million" 47 | 1000000000 nil "1.0 billion" 48 | 2000000000 nil "2.0 billion" 49 | 6000000000000 nil "6.0 trillion" 50 | 1300000000000000 nil "1.3 quadrillion" 51 | 3500000000000000000000 nil "3.5 sextillion" 52 | 8100000000000000000000000000000000 nil "8.1 decillion" 53 | 1230000 "%.2f" "1.23 million" 54 | (expt 10 101) nil "10.0 googol")) 55 | 56 | (deftest numberword-test 57 | (are [input expected] (= expected (numberword input)) 58 | 0 "zero" 59 | 7 "seven" 60 | 12 "twelve" 61 | 40 "forty" 62 | 94 "ninety-four" 63 | 100 "one hundred" 64 | 51 "fifty-one" 65 | 234 "two hundred and thirty-four" 66 | 1000 "one thousand" 67 | 3567 "three thousand five hundred and sixty-seven" 68 | 44120 "forty-four thousand one hundred and twenty" 69 | 25223 "twenty-five thousand two hundred and twenty-three" 70 | 5223 "five thousand two hundred and twenty-three" 71 | 1000000 "one million" 72 | 23237897 "twenty-three million two hundred and thirty-seven thousand eight hundred and ninety-seven")) 73 | 74 | (deftest filesize-test 75 | (let [f (fn [input binary format] 76 | (apply filesize input 77 | (cond-> [] 78 | binary (conj :binary true) 79 | format (conj :format format))))] 80 | (are [input binary format expected] (is (= expected (f input binary format))) 81 | 0 nil nil "0" 82 | 300 nil nil "300.0B" 83 | 3000 nil nil "3.0KB" 84 | 3000000 nil nil "3.0MB" 85 | 3005000 nil "%.3f" "3.005MB" 86 | 3000000000 nil nil "3.0GB" 87 | 3000000000000 nil nil "3.0TB" 88 | 3000 true nil "2.9KiB" 89 | 3000000 true nil "2.9MiB" 90 | (* (expt 10 26) 30) nil nil "3000.0YB" 91 | (* (expt 10 26) 30) true nil "2481.5YiB"))) 92 | 93 | (deftest truncate-test 94 | (testing "truncate should not return a string larger than the given length." 95 | (let [string "asdfghjkl"] 96 | (is (= (count (truncate string 7)) 7)) 97 | (is (= (count (truncate string 7 "1234")) 7)) 98 | (is (= (count (truncate string 100)) (count string))))) 99 | 100 | (testing "testing truncate with expected data." 101 | (let [string "abcdefghijklmnopqrstuvwxyz"] 102 | ;; Strng is truncated to a total of 14 including the (default) suffix: 103 | (is (= (truncate string 14) "abcdefghijklm…")) 104 | (is (= (truncate string 14 "...kidding") "abcd...kidding"))))) 105 | 106 | (deftest oxford-test 107 | (let [items ["apple", "orange" "banana" "pear" "pineapple" "strawberry"]] 108 | (testing "should return an empty string when given an empty list." 109 | (is (= "" (oxford [])))) 110 | 111 | (testing "should return a string version of a list that has only one value." 112 | (is (= (items 0) 113 | (oxford [(items 0)])))) 114 | 115 | (testing "should return a string with no commas & items separated with `and` when passed exactly two values in list" 116 | (is (= "apple and orange" 117 | (oxford (take 2 items))))) 118 | 119 | (testing "should return items separated by `and' when given a list of values" 120 | (is (= "apple, orange, and banana" 121 | (oxford (take 3 items)))) 122 | (is (= "apple, orange, banana, and pear" 123 | (oxford (take 4 items))))) 124 | 125 | (testing "should truncate a large list of items with proper pluralization" 126 | (is (= "apple, orange, banana, pear, and 1 other" 127 | (oxford (take 5 items)))) 128 | 129 | (is (= "apple, orange, and 3 others" 130 | (oxford (take 5 items) 131 | :maximum-display 2))) 132 | (is (= "apple and 3 others" 133 | (oxford (take 4 items) 134 | :maximum-display 1)))) 135 | 136 | (testing "should use custom truncation nouns" 137 | (let [truncate-noun "fruit"] 138 | (is (= "apple, orange, banana, pear, and 2 other fruits" 139 | (oxford items 140 | :truncate-noun truncate-noun))) 141 | (is (= "apple, orange, and banana" 142 | (oxford (take 3 items) 143 | :truncate-noun truncate-noun))))) 144 | 145 | (testing "should allow for different output conversion for the extra item count" 146 | (is (= "apple, orange, and four others" 147 | (oxford items 148 | :maximum-display 2 149 | :number-format numberword))) 150 | 151 | (is (= "apple and five other fruits" 152 | (oxford items 153 | :maximum-display 1 154 | :truncate-noun "fruit" 155 | :number-format numberword)))))) 156 | 157 | (def datetime-test-phrases 158 | (let [one-decade-in-years 10 159 | one-century-in-years 100 160 | one-millenia-in-years 1000] 161 | [["a moment ago" identity] 162 | ["a moment ago" #(jt.ldt/minus-nanos % 1000)] 163 | ["in a moment" #(jt.ldt/plus-nanos % 1000)] 164 | 165 | ["10 seconds ago" #(jt.ldt/minus-seconds % 10)] 166 | ["1 second ago" #(jt.ldt/minus-seconds % 1)] 167 | ["in 10 seconds" #(jt.ldt/plus-seconds % 10)] 168 | ["in 1 second" #(jt.ldt/plus-seconds % 1)] 169 | 170 | ["10 minutes ago" #(jt.ldt/minus-minutes % 10)] 171 | ["in 10 minutes" #(jt.ldt/plus-minutes % 10)] 172 | ["1 minute ago" #(jt.ldt/minus-minutes % 1)] 173 | ["in 1 minute" #(jt.ldt/plus-minutes % 1)] 174 | 175 | ["10 hours ago" #(jt.ldt/minus-hours % 10)] 176 | ["in 10 hours" #(jt.ldt/plus-hours % 10)] 177 | ["1 hour ago" #(jt.ldt/minus-hours % 1)] 178 | ["in 1 hour" #(jt.ldt/plus-hours % 1)] 179 | 180 | ["5 days ago" #(jt.ldt/minus-days % 5)] 181 | ["in 5 days" #(jt.ldt/plus-days % 5)] 182 | ["1 day ago" #(jt.ldt/minus-days % 1)] 183 | ["in 1 day" #(jt.ldt/plus-days % 1)] 184 | 185 | ["3 weeks ago" #(jt.ldt/minus-weeks % 3)] 186 | ["in 3 weeks" #(jt.ldt/plus-weeks % 3)] 187 | ["1 week ago" #(jt.ldt/minus-weeks % 1)] 188 | ["in 1 week" #(jt.ldt/plus-weeks % 1)] 189 | 190 | ["2 months ago" #(jt.ldt/minus-months % 2)] 191 | ["in 2 months" #(jt.ldt/plus-months % 2)] 192 | ["10 months ago" #(jt.ldt/minus-months % 10)] 193 | ["in 10 months" #(jt.ldt/plus-months % 10)] 194 | ["1 month ago" #(jt.ldt/minus-months % 1)] 195 | ["in 1 month" #(jt.ldt/plus-months % 1)] 196 | 197 | ["3 years ago" #(jt.ldt/minus-years % 3)] 198 | ["in 3 years" #(jt.ldt/plus-years % 3)] 199 | ["1 year ago" #(jt.ldt/minus-years % 1)] 200 | ["in 1 year" #(jt.ldt/plus-years % 1)] 201 | 202 | ["3 decades ago" #(jt.ldt/minus-years % (* 3 one-decade-in-years))] 203 | ["in 3 decades" #(jt.ldt/plus-years % (* 3 one-decade-in-years))] 204 | ["1 decade ago" #(jt.ldt/minus-years % one-decade-in-years)] 205 | ["in 1 decade" #(jt.ldt/plus-years % one-decade-in-years)] 206 | 207 | ["3 centuries ago" #(jt.ldt/minus-years % (* 3 one-century-in-years))] 208 | ["in 3 centuries" #(jt.ldt/plus-years % (* 3 one-century-in-years))] 209 | ["1 century ago" #(jt.ldt/minus-years % one-century-in-years)] 210 | ["in 1 century" #(jt.ldt/plus-years % one-century-in-years)] 211 | 212 | ["3 millennia ago" #(jt.ldt/minus-years % (* 3 one-millenia-in-years))] 213 | ["in 3 millennia" #(jt.ldt/plus-years % (* 3 one-millenia-in-years))] 214 | ["1 millenium ago" #(jt.ldt/minus-years % one-millenia-in-years)] 215 | ["in 1 millenium" #(jt.ldt/plus-years % one-millenia-in-years)]])) 216 | 217 | (deftest datetime-test 218 | (let [t1-str "2022-01-01T01:00:00" 219 | t1 (jt.ldt/parse t1-str)] 220 | (is (= "a moment ago" 221 | (datetime (jt.ldt/now))) 222 | ":now-dt is optional") 223 | (testing "datetime accepts joda-time values" 224 | (is (= "a moment ago" 225 | (datetime (jt.ldt/now) 226 | :now-dt (jt.ldt/now)))) 227 | (is (= "10 minutes ago" 228 | (datetime (jt.ldt/now) 229 | :now-dt (jt.ldt/plus-minutes (jt.ldt/now) 10))))) 230 | #?(:cljs 231 | (testing "datetime accepts js/Date" 232 | (is (= "a moment ago" (datetime (js/Date.)))) 233 | (is (= "10 minutes ago" 234 | (datetime (js/Date.) 235 | :now-dt (jt.ldt/plus-minutes (jt.ldt/now) 10)))))) 236 | (testing "test phrases" 237 | (doseq [[phrase time-shift-fn] datetime-test-phrases] 238 | (is (= phrase 239 | (datetime (time-shift-fn t1) 240 | :now-dt t1))))) 241 | (testing "suffix and prefix" 242 | (is (= "10 minutes ago" 243 | (datetime (jt.ldt/minus-minutes t1 10) 244 | :prefix "foo" 245 | :now-dt t1)) 246 | "prefix for a time in the past does nothing") 247 | (is (= "10 minutes in the glorious past" 248 | (datetime (jt.ldt/minus-minutes t1 10) 249 | :suffix "in the glorious past" 250 | :now-dt t1))) 251 | (is (= "forward, into our bright future 1 year" 252 | (datetime (jt.ldt/plus-years t1 1) 253 | :now-dt t1 254 | :prefix "forward, into our bright future"))) 255 | (is (= "in 1 year" 256 | (datetime (jt.ldt/plus-years t1 1) 257 | :now-dt t1 258 | :suffix "foo")) 259 | "suffix for a time in the past does nothing")))) 260 | 261 | (deftest durations 262 | (testing "duration to terms" 263 | (are [duration terms] (= terms (#'h/duration-terms duration)) 264 | ;; Less than a second is ignored 265 | 0 [] 266 | 999 [] 267 | 1000 [[1 "second"]] 268 | ;; Remaining milliseconds after seconds are gnored 269 | 1500 [[1 "second"]] 270 | ;; 0 periods are excluded 271 | 10805000 [[3 "hour"] 272 | [5 "second"]])) 273 | (testing "duration to string" 274 | (are [ms expected] (= expected (duration ms)) 275 | 0 "less than a second" 276 | 999 "less than a second" 277 | 1000 "one second" 278 | 10805000 "three hours, five seconds") 279 | 280 | (are [ms options expected] (= expected (duration ms options)) 281 | 999 {:short-text "just now"} "just now" 282 | 10805000 {:number-format str} "3 hours, 5 seconds" 283 | 510805000 {:number-format str 284 | :list-format oxford} "5 days, 21 hours, 53 minutes, and 25 seconds"))) 285 | -------------------------------------------------------------------------------- /test/clj_commons/inflect_test.cljc: -------------------------------------------------------------------------------- 1 | (ns clj-commons.inflect-test 2 | (:require #?(:clj [clojure.test :refer [deftest testing is are]] 3 | :cljs [cljs.test :refer-macros [deftest testing is are]]) 4 | [clj-commons.humanize.inflect :refer [pluralize-noun]])) 5 | 6 | (deftest pluralize-noun-test 7 | 8 | (testing "a count of one returns the standard value" 9 | (are [noun] (= noun (pluralize-noun 1 noun)) 10 | "kiss" 11 | "robot" 12 | "ox")) 13 | 14 | (testing "zero is considered plural" 15 | (are [noun expected-noun] (= expected-noun (pluralize-noun 0 noun)) 16 | "kiss" "kisses" 17 | "robot" "robots" 18 | "ox" "oxen")) 19 | 20 | (testing "nouns ending in a sibilant sound" 21 | (is (= (pluralize-noun 2 "kiss") "kisses")) 22 | (is (= (pluralize-noun 2 "phase") "phases")) 23 | (is (= (pluralize-noun 2 "dish") "dishes")) 24 | (is (= (pluralize-noun 2 "witch") "witches"))) 25 | 26 | (testing "nouns ending in y" 27 | (is (= (pluralize-noun 2 "boy") "boys")) 28 | (is (= (pluralize-noun 2 "holiday") "holidays")) 29 | (is (= (pluralize-noun 2 "party") "parties")) 30 | (is (= (pluralize-noun 2 "nanny") "nannies"))) 31 | 32 | (testing "nouns ending in F o FE" 33 | (is (= (pluralize-noun 2 "life") "lives")) 34 | (is (= (pluralize-noun 2 "thief") "thieves")) 35 | (is (= (pluralize-noun 2 "chief") "chiefs")) 36 | (is (= (pluralize-noun 2 "roof") "roofs")) 37 | (is (= (pluralize-noun 2 "staff") "staffs"))) 38 | 39 | (testing "general nouns" 40 | (is (= (pluralize-noun 2 "car") "cars")) 41 | (is (= (pluralize-noun 2 "house") "houses")) 42 | (is (= (pluralize-noun 2 "book") "books")) 43 | (is (= (pluralize-noun 2 "bird") "birds")) 44 | (is (= (pluralize-noun 2 "pencil") "pencils"))) 45 | 46 | (testing "irregulars nouns" 47 | (are [noun expected-noun] (= expected-noun (pluralize-noun 2 noun)) 48 | "ox" "oxen" 49 | "moose" "moose" 50 | "hero" "heroes" 51 | "cactus" "cactuses"))) 52 | --------------------------------------------------------------------------------