@quenty/metricutils 2.4.3 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +16 -54
  2. package/README.md +80 -2
  3. package/deploy.nevermore.json +10 -0
  4. package/package.json +22 -4
  5. package/src/Shared/Definitions/MetricUnitDefinitions.lua +1082 -0
  6. package/src/Shared/Definitions/MetricUnitDefinitions.spec.lua +55 -0
  7. package/src/Shared/Definitions/MetricUnitDimensionUtils.lua +164 -0
  8. package/src/Shared/Definitions/MetricUnitDimensionUtils.spec.lua +49 -0
  9. package/src/Shared/Definitions/MetricUnitKinds.lua +237 -0
  10. package/src/Shared/Definitions/MetricUnitKinds.spec.lua +51 -0
  11. package/src/Shared/Formatting/MetricUnitDisplayUnitUtils.lua +129 -0
  12. package/src/Shared/Formatting/MetricUnitFormatUtils.lua +924 -0
  13. package/src/Shared/Formatting/MetricUnitFormatUtils.spec.lua +280 -0
  14. package/src/Shared/Formatting/RxMetricUnitFormatUtils.lua +62 -0
  15. package/src/Shared/Localization/MetricUnitFallbackStrings.lua +289 -0
  16. package/src/Shared/Localization/MetricUnitFallbackStrings.spec.lua +82 -0
  17. package/src/Shared/Localization/MetricUnitTranslator/ar.json.txt +182 -0
  18. package/src/Shared/Localization/MetricUnitTranslator/de.json.txt +182 -0
  19. package/src/Shared/Localization/MetricUnitTranslator/en.json.txt +182 -0
  20. package/src/Shared/Localization/MetricUnitTranslator/es.json.txt +182 -0
  21. package/src/Shared/Localization/MetricUnitTranslator/fr.json.txt +182 -0
  22. package/src/Shared/Localization/MetricUnitTranslator/id.json.txt +182 -0
  23. package/src/Shared/Localization/MetricUnitTranslator/init.lua +92 -0
  24. package/src/Shared/Localization/MetricUnitTranslator/it.json.txt +182 -0
  25. package/src/Shared/Localization/MetricUnitTranslator/ja.json.txt +182 -0
  26. package/src/Shared/Localization/MetricUnitTranslator/ko.json.txt +182 -0
  27. package/src/Shared/Localization/MetricUnitTranslator/pl.json.txt +182 -0
  28. package/src/Shared/Localization/MetricUnitTranslator/pt.json.txt +182 -0
  29. package/src/Shared/Localization/MetricUnitTranslator/ru.json.txt +182 -0
  30. package/src/Shared/Localization/MetricUnitTranslator/th.json.txt +182 -0
  31. package/src/Shared/Localization/MetricUnitTranslator/tr.json.txt +182 -0
  32. package/src/Shared/Localization/MetricUnitTranslator/vi.json.txt +182 -0
  33. package/src/Shared/Localization/MetricUnitTranslator/zh-cn.json.txt +182 -0
  34. package/src/Shared/Localization/MetricUnitTranslator/zh-tw.json.txt +182 -0
  35. package/src/Shared/Localization/MetricUnitTranslator.spec.lua +309 -0
  36. package/src/Shared/Localization/PluralCategoryUtils.lua +103 -0
  37. package/src/Shared/Localization/PluralCategoryUtils.spec.lua +77 -0
  38. package/src/Shared/Logarithmic/DecibelUtils.lua +72 -0
  39. package/src/Shared/Logarithmic/DecibelUtils.spec.lua +35 -0
  40. package/src/Shared/MetricUnit.Angles.spec.lua +80 -0
  41. package/src/Shared/MetricUnit.Customary.spec.lua +104 -0
  42. package/src/Shared/MetricUnit.Derived.spec.lua +173 -0
  43. package/src/Shared/MetricUnit.Display.spec.lua +153 -0
  44. package/src/Shared/MetricUnit.Electrical.spec.lua +114 -0
  45. package/src/Shared/MetricUnit.Math.spec.lua +112 -0
  46. package/src/Shared/MetricUnit.Roblox.spec.lua +34 -0
  47. package/src/Shared/MetricUnit.SI.spec.lua +127 -0
  48. package/src/Shared/MetricUnit.Spring.spec.lua +94 -0
  49. package/src/Shared/MetricUnit.Temperature.spec.lua +212 -0
  50. package/src/Shared/MetricUnit.Time.spec.lua +202 -0
  51. package/src/Shared/MetricUnit.Units.spec.lua +210 -0
  52. package/src/Shared/MetricUnit.lua +1730 -0
  53. package/src/Shared/MetricUnit.spec.lua +362 -0
  54. package/src/Shared/MetricUtils.lua +47 -16
  55. package/src/Shared/MetricUtils.spec.lua +76 -0
  56. package/src/Shared/Parsing/MetricUnitParseUtils.lua +799 -0
  57. package/src/Shared/Parsing/MetricUnitParseUtils.spec.lua +189 -0
  58. package/src/Shared/Systems/MetricUnitSystem.lua +322 -0
  59. package/src/Shared/Systems/MetricUnitSystem.spec.lua +181 -0
  60. package/src/Shared/Systems/MetricUnitSystems.lua +225 -0
  61. package/src/jest.config.lua +3 -0
  62. package/src/node_modules.project.json +7 -0
  63. package/test/default.project.json +25 -0
  64. package/test/scripts/Server/ServerMain.server.lua +14 -0
@@ -0,0 +1,924 @@
1
+ --!strict
2
+ --[=[
3
+ Writes measurements for people, in the style of JavaScript's `Intl.NumberFormat` with `style: "unit"`: a
4
+ localized number, then the unit's symbol or name. The unit is picked to suit the value's size unless one is
5
+ given, so 1,500 N is `1.5 kN`. Takes a [MetricUnit] or its plain [MetricUnitData].
6
+
7
+ Numbers go through [NumberLocalizationUtils], so digit grouping and the decimal mark follow the locale. Unit
8
+ names come from [MetricUnitFallbackStrings], and duration words from [TimeLocalizationUtils].
9
+
10
+ A duration is broken into years, months, days, hours, minutes and seconds with [MetricUnit]'s own averaged year
11
+ and month (a Julian year, and a twelfth of it), not `@quenty/time`'s 365 and 30 days, so `MetricUnit.years(1)`
12
+ reads `1 year` and `MetricUnit.months(1)` reads `1 month`. Only `@quenty/time`'s words are used. For a clock-style
13
+ `h:mm:ss`, or calendar months, hand exact seconds to `TimeDurationUtils` or `Time` yourself.
14
+
15
+ ```lua
16
+ MetricUnitFormatUtils.format(MetricUnit.meters(1500)) --> 1.5 km
17
+ MetricUnitFormatUtils.format(MetricUnit.meters(1500), { unitDisplay = "long" }) --> 1.5 kilometers
18
+ MetricUnitFormatUtils.format(MetricUnit.meters(1500):ToUSCustomary()) --> 4,921.26 ft
19
+ MetricUnitFormatUtils.format({ amount = 1234.5, dimensions = { length = 1 } }, { unit = "meter", locale = "de-de" }) --> 1 234,5 m
20
+ MetricUnitFormatUtils.format(MetricUnit.minutes(90)) --> 1 hour, 30 minutes
21
+ MetricUnitFormatUtils.formatUnit("kilowattHour", 3) --> 3 kWh
22
+ ```
23
+
24
+ @class MetricUnitFormatUtils
25
+ ]=]
26
+
27
+ local require = require(script.Parent.loader).load(script)
28
+
29
+ local MetricUnitDefinitions = require("MetricUnitDefinitions")
30
+ local MetricUnitDimensionUtils = require("MetricUnitDimensionUtils")
31
+ local MetricUnitDisplayUnitUtils = require("MetricUnitDisplayUnitUtils")
32
+ local MetricUnitFallbackStrings = require("MetricUnitFallbackStrings")
33
+ local MetricUnitKinds = require("MetricUnitKinds")
34
+ local MetricUnitSystem = require("MetricUnitSystem")
35
+ local NumberLocalizationUtils = require("NumberLocalizationUtils")
36
+ local PluralCategoryUtils = require("PluralCategoryUtils")
37
+ local Time = require("Time")
38
+ local TimeDurationUtils = require("TimeDurationUtils")
39
+ local TimeLocalizationUtils = require("TimeLocalizationUtils")
40
+
41
+ local MetricUnitFormatUtils = {}
42
+
43
+ --[=[
44
+ The no-break space (U+00A0) that joins a number to its unit, so a line never wraps between them.
45
+
46
+ @prop NO_BREAK_SPACE string
47
+ @within MetricUnitFormatUtils
48
+ ]=]
49
+ MetricUnitFormatUtils.NO_BREAK_SPACE = MetricUnitFallbackStrings.NO_BREAK_SPACE
50
+
51
+ local DEFAULT_LOCALE = "en-us"
52
+ local DEFAULT_MAXIMUM_FRACTION_DIGITS = 3
53
+ local DEFAULT_DURATION_PARTS = 2
54
+ local LARGEST_GROUPED_INTEGER = 1e15
55
+ local DECIMAL_SEPARATOR_PROBE = 0.5
56
+ local ENGLISH_PLURAL_RULES = "en"
57
+ local YEAR_OR_MONTH_CLOCK_TOKENS = "[yM]"
58
+ -- Keeps twelve averaged months, summed in floating point, a whole year
59
+ local WHOLE_UNIT_TOLERANCE = 1e-9
60
+ local NUMBER_MARK = "\u{E000}"
61
+ local UNIT_MARK = "\u{E001}"
62
+
63
+ --[=[
64
+ `"short"` writes the unit's symbol (`5 km`), `"long"` its name (`5 kilometers`).
65
+
66
+ @type UnitDisplay "short" | "long"
67
+ @within MetricUnitFormatUtils
68
+ ]=]
69
+ export type UnitDisplay = "short" | "long"
70
+
71
+ --[=[
72
+ A measurement as plain data, for saves and for formatting without a [MetricUnit]: its amount in SI base units,
73
+ its exponents, and whether it is absolute. [MetricUnit.toData] and [MetricUnit.fromData] convert to and from it.
74
+
75
+ @interface MetricUnitData
76
+ .amount number
77
+ .dimensions { [string]: number }
78
+ .absolute boolean?
79
+ @within MetricUnitFormatUtils
80
+ ]=]
81
+ export type MetricUnitData = {
82
+ amount: number,
83
+ dimensions: { [string]: number },
84
+ absolute: boolean?,
85
+ }
86
+
87
+ --[=[
88
+ How a duration is written: in words through `@quenty/time`'s duration phrases, or as a clock.
89
+
90
+ @type DurationStyle "words" | "clock"
91
+ @within MetricUnitFormatUtils
92
+ ]=]
93
+ export type DurationStyle = "words" | "clock"
94
+
95
+ --[=[
96
+ What the formatter needs of [MetricUnitTranslator]: a `JSONTranslator`'s `FormatByKey`, `GetLocaleId` and,
97
+ for [RxMetricUnitFormatUtils.observeFormat], `ObserveFormatByKey`.
98
+
99
+ @interface MetricUnitTranslatorLike
100
+ .FormatByKey (self, key: string, args: { [string]: any }?) -> string
101
+ .GetLocaleId (self) -> string
102
+ .ObserveFormatByKey (self, key: string, args: { [string]: any }?) -> Observable<string>
103
+ @within MetricUnitFormatUtils
104
+ ]=]
105
+ export type MetricUnitTranslatorLike = {
106
+ FormatByKey: (any, string, { [string]: any }?) -> string,
107
+ GetLocaleId: (any) -> string,
108
+ ObserveFormatByKey: (any, string, { [string]: any }?) -> any,
109
+ }
110
+
111
+ --[=[
112
+ Options for [MetricUnitFormatUtils.format], modelled on `Intl.NumberFormat`.
113
+
114
+ @interface MetricUnitFormatOptions
115
+ .locale string? -- Picks the number format and unit names, defaults to `en-us`
116
+ .unitDisplay UnitDisplay? -- `short` symbols (the default) or `long` names
117
+ .unit UnitLike? -- Always write this unit; for a rate, the whole rate's (`kilometerPerHour`, `perHour`) or the numerator's (`kilometer`)
118
+ .perUnit TimeUnit? -- Always write a rate per this time unit, such as `"hour"` or `"h"`
119
+ .autoUnit boolean? -- Pick the unit that suits the size (the default), or always write the SI base unit
120
+ .minimumFractionDigits number? -- Pad with zeros to at least this many decimals, defaults to 0
121
+ .maximumFractionDigits number? -- Round to at most this many decimals, defaults to 3
122
+ .maximumSignificantDigits number? -- Round to this many significant digits instead of fraction digits
123
+ .useGrouping boolean? -- Group thousands for the locale, defaults to true
124
+ .strings UnitStringOverrides? -- Overrides a unit's phrase, e.g. `{ kilometer = { short = "%s klicks" } }`
125
+ .perStrings PerStringOverrides? -- Overrides a per phrase, e.g. `{ hour = { short = "%s an hour" } }`
126
+ .durationStrings DurationStringOverrides? -- Overrides a duration word, as in `TimeDurationUtils`
127
+ .maxParts number? -- How many non-zero units a duration is written in, largest first, defaults to 2
128
+ .separator string? -- Between the parts of a duration, defaults to `, `
129
+ .durationStyle DurationStyle? -- `words` (the default, `1 hour, 30 minutes`) or `clock` (`1:30:00`, or `1:30` under an hour)
130
+ .durationTemplate string? -- A `TimeDurationUtils.format` template such as `hh:mm:ss`; years and months are refused
131
+ .translator MetricUnitTranslatorLike? -- [MetricUnitTranslator], for long names in the player's language
132
+ @within MetricUnitFormatUtils
133
+ ]=]
134
+ export type MetricUnitFormatOptions = {
135
+ locale: string?,
136
+ unitDisplay: UnitDisplay?,
137
+ unit: MetricUnitDefinitions.UnitLike?,
138
+ perUnit: Time.TimeUnit?,
139
+ autoUnit: boolean?,
140
+ minimumFractionDigits: number?,
141
+ maximumFractionDigits: number?,
142
+ maximumSignificantDigits: number?,
143
+ useGrouping: boolean?,
144
+ strings: MetricUnitFallbackStrings.UnitStringOverrides?,
145
+ perStrings: MetricUnitFallbackStrings.PerStringOverrides?,
146
+ durationStrings: TimeLocalizationUtils.DurationStringOverrides?,
147
+ maxParts: number?,
148
+ separator: string?,
149
+ durationStyle: DurationStyle?,
150
+ durationTemplate: string?,
151
+ translator: MetricUnitTranslatorLike?,
152
+ _system: MetricUnitSystem.MetricUnitSystem?,
153
+ }
154
+
155
+ type UnitDefinition = MetricUnitDefinitions.UnitDefinition
156
+ type DurationUnit = { field: string, factor: number }
157
+
158
+ local DURATION_FIELDS_LARGEST_FIRST: { { field: string, unitId: string } } = table.freeze({
159
+ { field = "years", unitId = "year" },
160
+ { field = "months", unitId = "month" },
161
+ { field = "days", unitId = "day" },
162
+ { field = "hours", unitId = "hour" },
163
+ { field = "minutes", unitId = "minute" },
164
+ { field = "seconds", unitId = "second" },
165
+ { field = "milliseconds", unitId = "millisecond" },
166
+ })
167
+ local durationUnitsLargestFirst: { DurationUnit }? = nil
168
+
169
+ local TIME_LOCALIZATION_FIELD_BY_UNIT_ID: { [string]: string } = {
170
+ millisecond = "milliseconds",
171
+ second = "seconds",
172
+ minute = "minutes",
173
+ hour = "hours",
174
+ day = "days",
175
+ week = "weeks",
176
+ month = "months",
177
+ year = "years",
178
+ }
179
+
180
+ local SUPERSCRIPTS: { [number]: string } = { [2] = "²", [3] = "³" }
181
+
182
+ local decimalSeparators: { [string]: string } = {}
183
+
184
+ --[=[
185
+ Writes a measurement for the locale in a unit that suits its size, or the one `options.unit` names. Durations are
186
+ broken into parts with localized words, temperatures keep whether they are absolute (`5 °C`) or a difference
187
+ (`5 K`), and a rate is written per the shortest time unit it fills at least once (`1.6 MB/s`).
188
+
189
+ A value is written in the system of the unit it was made from, so `MetricUnit.new(5, "mile")` writes miles and
190
+ `MetricUnit.kilometers(8)` kilometres. Convert it to write another ([MetricUnit.ToMetric],
191
+ [MetricUnit.ToImperial]), or use [MetricUnitSystem.Format]. The locale never chooses a measurement system.
192
+
193
+ ```lua
194
+ local length = MetricUnit.meters(2500)
195
+ MetricUnitFormatUtils.format(length) --> 2.5 km
196
+ MetricUnitFormatUtils.format(length, { unit = "meter" }) --> 2,500 m
197
+ MetricUnitFormatUtils.format(length, { unit = "stud" }) --> 8,928.571 studs
198
+ MetricUnitFormatUtils.format(length, { unitDisplay = "long", locale = "en-gb" }) --> 2.5 kilometres
199
+ MetricUnitFormatUtils.format(length, { maximumSignificantDigits = 1 }) --> 3 km
200
+ ```
201
+
202
+ @param value MetricUnit | MetricUnitData
203
+ @param options MetricUnitFormatOptions?
204
+ @return string
205
+ ]=]
206
+ function MetricUnitFormatUtils.format(
207
+ -- any: MetricUnit requires this module, so its type cannot be named here; _readValue checks it at runtime
208
+ value: any,
209
+ options: MetricUnitFormatOptions?
210
+ ): string
211
+ local amount, dimensions, absolute, displaySystem, displayUnit = MetricUnitFormatUtils._readValue(value)
212
+ local resolved = MetricUnitFormatUtils._resolveOptionsWithValuePreference(options, displaySystem, displayUnit)
213
+
214
+ if MetricUnitDimensionUtils.equal(dimensions, MetricUnitKinds.TIME) then
215
+ return MetricUnitFormatUtils._formatDuration(amount, resolved)
216
+ end
217
+ return MetricUnitFormatUtils._formatRelative(amount, dimensions, resolved, resolved.unit, absolute)
218
+ end
219
+
220
+ function MetricUnitFormatUtils._formatInSystem(
221
+ value: any,
222
+ system: MetricUnitSystem.MetricUnitSystem,
223
+ options: MetricUnitFormatOptions?
224
+ ): string
225
+ local resolved: MetricUnitFormatOptions = if options then table.clone(options) else {}
226
+ resolved._system = system
227
+ return MetricUnitFormatUtils.format(value, resolved)
228
+ end
229
+
230
+ --[=[
231
+ Writes `amount` of the unit `unit`, as [MetricUnitFormatUtils.format] would, without picking another unit. A
232
+ temperature is absolute on its own scale, so `formatUnit("celsius", 20)` is `20 °C`.
233
+
234
+ ```lua
235
+ MetricUnitFormatUtils.formatUnit("kilometerPerHour", 90) --> 90 km/h
236
+ MetricUnitFormatUtils.formatUnit("liter", 1, { unitDisplay = "long" }) --> 1 liter
237
+ MetricUnitFormatUtils.formatUnit("hours", 1.5) --> 1.5 hours
238
+ ```
239
+
240
+ @param unit UnitLike
241
+ @param amount number -- in `unit`
242
+ @param options MetricUnitFormatOptions?
243
+ @return string
244
+ ]=]
245
+ function MetricUnitFormatUtils.formatUnit(
246
+ unit: MetricUnitDefinitions.UnitLike,
247
+ amount: number,
248
+ options: MetricUnitFormatOptions?
249
+ ): string
250
+ assert(type(amount) == "number", "Bad amount")
251
+ local resolved = MetricUnitFormatUtils._resolveOptionsWithValuePreference(options, nil, nil)
252
+ local definition = MetricUnitFormatUtils._getUnit(resolved, unit)
253
+ assert(definition, string.format("No unit %q", tostring(unit)))
254
+
255
+ if definition.duration then
256
+ return MetricUnitFormatUtils._durationUnitPhrase(definition, amount, resolved)
257
+ elseif definition.per then
258
+ local per = MetricUnitFormatUtils._perUnit(definition.per)
259
+ return MetricUnitFormatUtils._perPhrase(per, MetricUnitFormatUtils._formatNumber(amount, resolved), resolved)
260
+ end
261
+ return MetricUnitFormatUtils._unitPhrase(definition, amount, resolved)
262
+ end
263
+
264
+ --[=[
265
+ Writes a plain number for the locale: grouped thousands, the locale's decimal mark, and the digit options of
266
+ [MetricUnitFormatOptions].
267
+
268
+ ```lua
269
+ MetricUnitFormatUtils.formatNumber(1234.5678) --> 1,234.568
270
+ MetricUnitFormatUtils.formatNumber(1234.5, { locale = "de-de" }) --> 1 234,5
271
+ MetricUnitFormatUtils.formatNumber(2, { minimumFractionDigits = 2 }) --> 2.00
272
+ ```
273
+
274
+ @param value number
275
+ @param options MetricUnitFormatOptions?
276
+ @return string
277
+ ]=]
278
+ function MetricUnitFormatUtils.formatNumber(value: number, options: MetricUnitFormatOptions?): string
279
+ assert(type(value) == "number", "Bad value")
280
+ local text = MetricUnitFormatUtils._formatNumber(value, options or {})
281
+ return text
282
+ end
283
+
284
+ function MetricUnitFormatUtils._readValue(value: any): (
285
+ number,
286
+ MetricUnitDimensionUtils.Dimensions,
287
+ boolean,
288
+ MetricUnitSystem.MetricUnitSystem?,
289
+ UnitDefinition?
290
+ )
291
+ assert(type(value) == "table", "Bad value")
292
+ if type(value.amount) == "number" then
293
+ assert(MetricUnitDimensionUtils.isDimensions(value.dimensions), "Bad dimensions")
294
+ return value.amount, value.dimensions, value.absolute == true, nil, nil
295
+ end
296
+ assert(value.ClassName == "MetricUnit", "Bad value, expected a MetricUnit or MetricUnitData")
297
+ return value:GetBaseAmount(),
298
+ value:GetDimensions(),
299
+ value:IsAbsolute(),
300
+ value:GetDisplaySystem(),
301
+ value:GetDisplayUnit()
302
+ end
303
+
304
+ function MetricUnitFormatUtils._resolveOptionsWithValuePreference(
305
+ options: MetricUnitFormatOptions?,
306
+ displaySystem: MetricUnitSystem.MetricUnitSystem?,
307
+ displayUnit: UnitDefinition?
308
+ ): MetricUnitFormatOptions
309
+ local resolved: MetricUnitFormatOptions = if options then table.clone(options) else {}
310
+ if resolved.locale == nil and resolved.translator then
311
+ resolved.locale = resolved.translator:GetLocaleId()
312
+ end
313
+ if resolved._system == nil then
314
+ resolved._system = displaySystem
315
+ end
316
+ if resolved.unit == nil and displayUnit then
317
+ resolved.unit = displayUnit
318
+ end
319
+ return resolved
320
+ end
321
+
322
+ function MetricUnitFormatUtils._getUnit(
323
+ options: MetricUnitFormatOptions,
324
+ unit: string | UnitDefinition
325
+ ): UnitDefinition?
326
+ local system = options._system
327
+ return if system then system:GetUnit(unit) else MetricUnitDefinitions.getUnit(unit)
328
+ end
329
+
330
+ function MetricUnitFormatUtils._tryTranslate(
331
+ options: MetricUnitFormatOptions,
332
+ keys: { string },
333
+ args: { [string]: string }
334
+ ): string?
335
+ local translator = options.translator
336
+ if not translator then
337
+ return nil
338
+ end
339
+ for _, key in keys do
340
+ local ok, text = pcall(translator.FormatByKey, translator, key, args)
341
+ if ok and type(text) == "string" and text ~= key then
342
+ return text
343
+ end
344
+ end
345
+ return nil
346
+ end
347
+
348
+ function MetricUnitFormatUtils._formatNumber(value: number, options: MetricUnitFormatOptions): (string, number, string)
349
+ if value ~= value or value == math.huge or value == -math.huge then
350
+ local noFractionDigits = ""
351
+ return tostring(value), value, noFractionDigits
352
+ end
353
+
354
+ local locale = options.locale or DEFAULT_LOCALE
355
+ local minimumFractionDigits = options.minimumFractionDigits or 0
356
+
357
+ local rounded, decimals
358
+ if options.maximumSignificantDigits then
359
+ local significant = options.maximumSignificantDigits
360
+ if value == 0 then
361
+ rounded, decimals = 0, 0
362
+ else
363
+ local places = significant - (math.floor(math.log10(math.abs(value))) + 1)
364
+ rounded = MetricUnitFormatUtils._roundHalfAwayFromZero(value, places)
365
+ decimals = math.max(0, places)
366
+ end
367
+ else
368
+ decimals = options.maximumFractionDigits or DEFAULT_MAXIMUM_FRACTION_DIGITS
369
+ rounded = MetricUnitFormatUtils._roundHalfAwayFromZero(value, decimals)
370
+ end
371
+ decimals = math.max(decimals, minimumFractionDigits)
372
+
373
+ local digits = string.format("%." .. tostring(decimals) .. "f", math.abs(rounded))
374
+ local integerPart, shownFractionDigits = string.match(digits, "^(%d+)%.?(%d*)$")
375
+ assert(integerPart and shownFractionDigits, "Bad number")
376
+ while #shownFractionDigits > minimumFractionDigits and string.sub(shownFractionDigits, -1) == "0" do
377
+ shownFractionDigits = string.sub(shownFractionDigits, 1, -2)
378
+ end
379
+
380
+ local integerValue = tonumber(integerPart) :: number
381
+ if options.useGrouping ~= false and integerValue < LARGEST_GROUPED_INTEGER then
382
+ integerPart = NumberLocalizationUtils.localize(integerValue, locale)
383
+ end
384
+
385
+ local text = (if rounded < 0 then "-" else "") .. integerPart
386
+ if #shownFractionDigits > 0 then
387
+ text ..= MetricUnitFormatUtils.getDecimalSeparator(locale) .. shownFractionDigits
388
+ end
389
+ return text, rounded, shownFractionDigits
390
+ end
391
+
392
+ function MetricUnitFormatUtils._roundHalfAwayFromZero(value: number, decimalPlacesOrNegativeForTens: number): number
393
+ local factor = 10 ^ decimalPlacesOrNegativeForTens
394
+ local rounded = math.floor(math.abs(value) * factor + 0.5) / factor
395
+ return if value < 0 and rounded ~= 0 then -rounded else rounded
396
+ end
397
+
398
+ --[=[
399
+ The decimal mark `locale` writes numbers with, such as `"."` or `","`.
400
+
401
+ @param locale string
402
+ @return string
403
+ ]=]
404
+ function MetricUnitFormatUtils.getDecimalSeparator(locale: string): string
405
+ local cached = decimalSeparators[locale]
406
+ if cached then
407
+ return cached
408
+ end
409
+
410
+ local separator = MetricUnitFormatUtils._readDecimalSeparatorFromAbbreviate(locale)
411
+ decimalSeparators[locale] = separator
412
+ return separator
413
+ end
414
+
415
+ -- NumberLocalizationUtils.localize always writes "." as the decimal mark; abbreviate writes the locale's
416
+ function MetricUnitFormatUtils._readDecimalSeparatorFromAbbreviate(locale: string): string
417
+ local probe = NumberLocalizationUtils.abbreviate(DECIMAL_SEPARATOR_PROBE, locale, nil, 1)
418
+ return string.match(probe, "^0(.-)5$") or "."
419
+ end
420
+
421
+ function MetricUnitFormatUtils._getUnitLocale(options: MetricUnitFormatOptions): MetricUnitFallbackStrings.UnitLocale
422
+ return MetricUnitFallbackStrings.getLocale(MetricUnitFormatUtils._fallbackStringsLocale(options))
423
+ end
424
+
425
+ function MetricUnitFormatUtils._fallbackStringsLocale(options: MetricUnitFormatOptions): string?
426
+ local writesSymbolsThatAreTheSameInEveryLocale = options.unitDisplay ~= "long"
427
+ if writesSymbolsThatAreTheSameInEveryLocale and not MetricUnitFallbackStrings.hasLocale(options.locale) then
428
+ return DEFAULT_LOCALE
429
+ end
430
+ return options.locale
431
+ end
432
+
433
+ function MetricUnitFormatUtils._pickPluralTemplate(
434
+ phrase: { one: string, other: string },
435
+ pluralRulesLocale: string?,
436
+ rounded: number,
437
+ fractionDigits: string
438
+ ): string
439
+ local category = PluralCategoryUtils.getPluralCategory(pluralRulesLocale, rounded, fractionDigits)
440
+ return if category == "one" then phrase.one else phrase.other
441
+ end
442
+
443
+ function MetricUnitFormatUtils._applyUnitPhrase(
444
+ phrase: MetricUnitFallbackStrings.UnitPhrase,
445
+ pluralRulesLocale: string?,
446
+ rounded: number,
447
+ numberText: string,
448
+ fraction: string
449
+ ): string
450
+ if type(phrase) == "function" then
451
+ return phrase(rounded, numberText)
452
+ end
453
+ local template = MetricUnitFormatUtils._pickPluralTemplate(phrase, pluralRulesLocale, rounded, fraction)
454
+ return (string.gsub(template, "%%s", function()
455
+ return numberText
456
+ end))
457
+ end
458
+
459
+ function MetricUnitFormatUtils._unitPhrase(
460
+ definition: UnitDefinition,
461
+ amount: number,
462
+ options: MetricUnitFormatOptions,
463
+ display: UnitDisplay?
464
+ ): string
465
+ local numberText, rounded, fraction = MetricUnitFormatUtils._formatNumber(amount, options)
466
+ local resolvedDisplay: UnitDisplay = display or options.unitDisplay or "short"
467
+
468
+ local override = if options.strings then options.strings[definition.id] else nil
469
+ local phrase = if override then (override :: any)[resolvedDisplay] else nil
470
+ if phrase == nil and options.translator then
471
+ local category = PluralCategoryUtils.getPluralCategory(options.locale, rounded, fraction)
472
+ local keys = {
473
+ MetricUnitFormatUtils._unitTranslationKey(definition.id, resolvedDisplay, category),
474
+ MetricUnitFormatUtils._unitTranslationKey(definition.id, resolvedDisplay, "other"),
475
+ }
476
+ local translated = MetricUnitFormatUtils._tryTranslate(options, keys, { n = numberText })
477
+ or MetricUnitFormatUtils._translatePrefixed(options, definition, resolvedDisplay, category, numberText)
478
+ if translated then
479
+ return translated
480
+ end
481
+ end
482
+ local pluralRulesLocale = options.locale
483
+ if phrase == nil then
484
+ local locale = MetricUnitFormatUtils._fallbackStringsLocale(options)
485
+ phrase = (MetricUnitFallbackStrings.getUnitNames(locale, definition) :: any)[resolvedDisplay]
486
+ pluralRulesLocale = ENGLISH_PLURAL_RULES
487
+ end
488
+
489
+ return MetricUnitFormatUtils._applyUnitPhrase(phrase, pluralRulesLocale, rounded, numberText, fraction)
490
+ end
491
+
492
+ function MetricUnitFormatUtils._translatePrefixed(
493
+ options: MetricUnitFormatOptions,
494
+ definition: UnitDefinition,
495
+ display: UnitDisplay,
496
+ category: string,
497
+ numberText: string
498
+ ): string?
499
+ local prefix = definition.prefix
500
+ if not prefix then
501
+ return nil
502
+ end
503
+ local baseText = MetricUnitFormatUtils._tryTranslate(options, {
504
+ MetricUnitFormatUtils._unitTranslationKey(prefix.base, display, category),
505
+ MetricUnitFormatUtils._unitTranslationKey(prefix.base, display, "other"),
506
+ }, { n = NUMBER_MARK })
507
+ local prefixPattern = MetricUnitFormatUtils._tryTranslate(
508
+ options,
509
+ { "prefixes." .. prefix.name .. "." .. display },
510
+ { unit = UNIT_MARK }
511
+ )
512
+ if not (baseText and prefixPattern) then
513
+ return nil
514
+ end
515
+
516
+ local unitText = MetricUnitFormatUtils._withoutNumber(baseText)
517
+ local start, finish = string.find(baseText, unitText, 1, true)
518
+ if unitText == "" or not start then
519
+ return nil
520
+ end
521
+ local prefixIsACapitalizedWordBeforeTheUnit = display == "long"
522
+ and string.match(prefixPattern, "^%u") ~= nil
523
+ and string.sub(prefixPattern, -#UNIT_MARK) == UNIT_MARK
524
+ local unitInPrefix = if prefixIsACapitalizedWordBeforeTheUnit
525
+ then string.lower(string.sub(unitText, 1, 1)) .. string.sub(unitText, 2)
526
+ else unitText
527
+ local prefixedUnit = string.gsub(prefixPattern, UNIT_MARK, function()
528
+ return unitInPrefix
529
+ end)
530
+ local text = string.sub(baseText, 1, start - 1) .. prefixedUnit .. string.sub(baseText, (finish :: number) + 1)
531
+ return (string.gsub(text, NUMBER_MARK, function()
532
+ return numberText
533
+ end))
534
+ end
535
+
536
+ function MetricUnitFormatUtils._withoutNumber(text: string): string
537
+ local unitText = string.gsub(text, NUMBER_MARK, "")
538
+ for _, space in { " ", MetricUnitFormatUtils.NO_BREAK_SPACE } do
539
+ while string.sub(unitText, 1, #space) == space do
540
+ unitText = string.sub(unitText, #space + 1)
541
+ end
542
+ while #unitText >= #space and string.sub(unitText, -#space) == space do
543
+ unitText = string.sub(unitText, 1, -#space - 1)
544
+ end
545
+ end
546
+ return unitText
547
+ end
548
+
549
+ function MetricUnitFormatUtils._durationUnitsLargestFirst(): { DurationUnit }
550
+ local found = durationUnitsLargestFirst
551
+ if found then
552
+ return found
553
+ end
554
+ local units: { DurationUnit } = {}
555
+ for _, duration in DURATION_FIELDS_LARGEST_FIRST do
556
+ local definition = assert(MetricUnitDefinitions.getUnit(duration.unitId), duration.unitId)
557
+ table.insert(units, { field = duration.field, factor = definition.factor })
558
+ end
559
+ local frozen = table.freeze(units)
560
+ durationUnitsLargestFirst = frozen
561
+ return frozen
562
+ end
563
+
564
+ function MetricUnitFormatUtils._unitTranslationKey(unitId: string, display: UnitDisplay, pluralCategory: string): string
565
+ return "units." .. unitId .. "." .. display .. "." .. pluralCategory
566
+ end
567
+
568
+ function MetricUnitFormatUtils._perUnit(unit: string): UnitDefinition
569
+ local per = MetricUnitDefinitions.getUnit(unit)
570
+ assert(
571
+ per and TIME_LOCALIZATION_FIELD_BY_UNIT_ID[per.id] and per.id ~= "millisecond",
572
+ string.format("Bad per unit %q, expected a second, minute, hour, day, week, month or year", unit)
573
+ )
574
+ return per
575
+ end
576
+
577
+ function MetricUnitFormatUtils._perPhrase(
578
+ per: UnitDefinition,
579
+ numeratorText: string,
580
+ options: MetricUnitFormatOptions
581
+ ): string
582
+ local display: UnitDisplay = options.unitDisplay or "short"
583
+
584
+ local override = if options.perStrings then options.perStrings[per.id] else nil
585
+ local template = if override then (override :: any)[display] else nil
586
+ if template == nil and options.translator then
587
+ local translated = MetricUnitFormatUtils._tryTranslate(
588
+ options,
589
+ { "per." .. per.id .. "." .. display },
590
+ { n = numeratorText }
591
+ )
592
+ if translated then
593
+ return translated
594
+ end
595
+ end
596
+ if template == nil then
597
+ local names = (MetricUnitFormatUtils._getUnitLocale(options).per :: any)[per.id]
598
+ assert(names, string.format("No per phrase for %q", per.id))
599
+ template = names[display]
600
+ end
601
+
602
+ return (string.gsub(template, "%%s", function()
603
+ return numeratorText
604
+ end))
605
+ end
606
+
607
+ function MetricUnitFormatUtils._formatRelative(
608
+ amount: number,
609
+ dimensions: MetricUnitDimensionUtils.Dimensions,
610
+ options: MetricUnitFormatOptions,
611
+ unit: (string | UnitDefinition)?,
612
+ absolute: boolean?
613
+ ): string
614
+ if next(dimensions) == nil and unit == nil then
615
+ return (MetricUnitFormatUtils._formatNumber(amount, options))
616
+ end
617
+
618
+ if unit then
619
+ local definition = MetricUnitFormatUtils._getUnit(options, unit)
620
+ if not definition then
621
+ error(
622
+ string.format("MetricUnitFormatUtils: no unit %q", if type(unit) == "string" then unit else unit.id),
623
+ 3
624
+ )
625
+ end
626
+ if MetricUnitDimensionUtils.equal(definition.dimensions, dimensions) then
627
+ if definition.per then
628
+ return MetricUnitFormatUtils._formatRate(amount, dimensions, options, nil, definition.per)
629
+ end
630
+ return MetricUnitFormatUtils._unitPhrase(
631
+ definition,
632
+ MetricUnitFormatUtils._amountIn(amount, definition, absolute),
633
+ options
634
+ )
635
+ elseif not MetricUnitFormatUtils._isPerTime(dimensions) then
636
+ error(
637
+ string.format(
638
+ "MetricUnitFormatUtils: %s is %s, not %s",
639
+ definition.id,
640
+ MetricUnitDimensionUtils.describe(definition.dimensions),
641
+ MetricUnitDimensionUtils.describe(dimensions)
642
+ ),
643
+ 3
644
+ )
645
+ end
646
+ end
647
+
648
+ local isRateWrittenAsAsked = (unit ~= nil or options.perUnit ~= nil)
649
+ and MetricUnitFormatUtils._isPerTime(dimensions)
650
+ if isRateWrittenAsAsked then
651
+ return MetricUnitFormatUtils._formatRate(amount, dimensions, options, unit, options.perUnit)
652
+ end
653
+
654
+ local units = MetricUnitDisplayUnitUtils.getDisplayUnits(dimensions, options._system, absolute)
655
+ if units then
656
+ local definition
657
+ if options.autoUnit == false then
658
+ definition = MetricUnitDisplayUnitUtils.getBaseUnit(
659
+ MetricUnitDisplayUnitUtils.getDisplayUnits(dimensions, nil, absolute) or units
660
+ )
661
+ else
662
+ definition = MetricUnitDisplayUnitUtils.pickDisplayUnit(amount, units)
663
+ end
664
+ return MetricUnitFormatUtils._unitPhrase(
665
+ definition,
666
+ MetricUnitFormatUtils._amountIn(amount, definition, absolute),
667
+ options
668
+ )
669
+ end
670
+
671
+ if MetricUnitFormatUtils._isPerTime(dimensions) then
672
+ return MetricUnitFormatUtils._formatRate(amount, dimensions, options, unit, options.perUnit)
673
+ end
674
+
675
+ return MetricUnitFormatUtils._formatInBaseUnitSymbols(amount, dimensions, options)
676
+ end
677
+
678
+ function MetricUnitFormatUtils._amountIn(amount: number, definition: UnitDefinition, absolute: boolean?): number
679
+ local fromZero = if absolute then amount - (definition.zero or 0) else amount
680
+ return fromZero / definition.factor
681
+ end
682
+
683
+ function MetricUnitFormatUtils._isPerTime(dimensions: MetricUnitDimensionUtils.Dimensions): boolean
684
+ if dimensions.time == -1 then
685
+ return true
686
+ end
687
+ local numerator = MetricUnitDimensionUtils.combine(dimensions, MetricUnitKinds.TIME, 1)
688
+ return MetricUnitDisplayUnitUtils.getDisplayUnits(numerator) ~= nil
689
+ end
690
+
691
+ function MetricUnitFormatUtils._formatRate(
692
+ amount: number,
693
+ dimensions: MetricUnitDimensionUtils.Dimensions,
694
+ options: MetricUnitFormatOptions,
695
+ numeratorUnit: (string | UnitDefinition)?,
696
+ perUnit: string?
697
+ ): string
698
+ local per = if perUnit
699
+ then MetricUnitFormatUtils._perUnit(perUnit)
700
+ else MetricUnitDisplayUnitUtils.pickDisplayPerUnit(amount)
701
+
702
+ local numerator = MetricUnitDimensionUtils.combine(dimensions, MetricUnitKinds.TIME, 1)
703
+ local numeratorText = MetricUnitFormatUtils._formatRelative(amount * per.factor, numerator, options, numeratorUnit)
704
+ return MetricUnitFormatUtils._perPhrase(per, numeratorText, options)
705
+ end
706
+
707
+ function MetricUnitFormatUtils._formatInBaseUnitSymbols(
708
+ amount: number,
709
+ dimensions: MetricUnitDimensionUtils.Dimensions,
710
+ options: MetricUnitFormatOptions
711
+ ): string
712
+ local names: { string } = {}
713
+ for name in dimensions do
714
+ table.insert(names, name)
715
+ end
716
+ table.sort(names)
717
+
718
+ local over: { string } = {}
719
+ local under: { string } = {}
720
+ for _, name in names do
721
+ local exponent = dimensions[name]
722
+ local symbol = MetricUnitDisplayUnitUtils.getBaseSymbol(name)
723
+ local power = math.abs(exponent)
724
+ local part = if power == 1 then symbol else symbol .. (SUPERSCRIPTS[power] or "^" .. tostring(power))
725
+ table.insert(if exponent > 0 then over else under, part)
726
+ end
727
+
728
+ local text = MetricUnitFormatUtils._formatNumber(amount, options)
729
+ if #over > 0 then
730
+ text ..= MetricUnitFormatUtils.NO_BREAK_SPACE .. table.concat(over, "·")
731
+ end
732
+ for _, part in under do
733
+ text ..= "/" .. part
734
+ end
735
+ return text
736
+ end
737
+
738
+ function MetricUnitFormatUtils._formatDuration(seconds: number, options: MetricUnitFormatOptions): string
739
+ if options.unit then
740
+ local definition = MetricUnitFormatUtils._getUnit(options, options.unit)
741
+ assert(
742
+ definition and definition.duration,
743
+ string.format("MetricUnitFormatUtils: %s is not a time unit", tostring(options.unit))
744
+ )
745
+ return MetricUnitFormatUtils._durationUnitPhrase(definition, seconds / definition.factor, options)
746
+ end
747
+
748
+ if options.durationTemplate or options.durationStyle == "clock" then
749
+ return MetricUnitFormatUtils._formatClock(seconds, options)
750
+ end
751
+
752
+ local magnitude = math.abs(seconds)
753
+ if magnitude == 0 then
754
+ return MetricUnitFormatUtils._durationPhrase("seconds", 0, options)
755
+ elseif magnitude < 1e-3 then
756
+ local microsecond = assert(MetricUnitDefinitions.getUnit("microsecond"), "No microsecond")
757
+ return MetricUnitFormatUtils._durationUnitPhrase(microsecond, seconds / microsecond.factor, options)
758
+ end
759
+
760
+ local parts = MetricUnitFormatUtils._breakDuration(magnitude, options.maxParts or DEFAULT_DURATION_PARTS)
761
+ local pieces: { string } = {}
762
+ for _, part in parts do
763
+ local count = if #pieces == 0 and seconds < 0 then -part.count else part.count
764
+ table.insert(pieces, MetricUnitFormatUtils._durationPhrase(part.field, count, options))
765
+ end
766
+ return table.concat(pieces, options.separator or ", ")
767
+ end
768
+
769
+ function MetricUnitFormatUtils._durationUnitPhrase(
770
+ definition: UnitDefinition,
771
+ amount: number,
772
+ options: MetricUnitFormatOptions
773
+ ): string
774
+ local timeLocalizationField = TIME_LOCALIZATION_FIELD_BY_UNIT_ID[definition.id]
775
+ if timeLocalizationField then
776
+ return MetricUnitFormatUtils._durationPhrase(timeLocalizationField, amount, options)
777
+ elseif definition.id == "microsecond" then
778
+ local hasLongNames = MetricUnitFallbackStrings.hasLocale(options.locale)
779
+ local display: UnitDisplay = if hasLongNames then "long" else "short"
780
+ return MetricUnitFormatUtils._unitPhrase(definition, amount, options, display)
781
+ end
782
+ error(string.format("MetricUnitFormatUtils: no duration word for %q", definition.id))
783
+ end
784
+
785
+ function MetricUnitFormatUtils._formatClock(seconds: number, options: MetricUnitFormatOptions): string
786
+ local template = options.durationTemplate or "h:*mm:ss"
787
+ local tokensOutsideBracketedLiterals = string.gsub(template, "%b[]", "")
788
+ if string.find(tokensOutsideBracketedLiterals, YEAR_OR_MONTH_CLOCK_TOKENS) then
789
+ error(
790
+ string.format(
791
+ 'MetricUnitFormatUtils: durationTemplate %q counts years or months, which TimeDurationUtils sizes differently from MetricUnit; use durationStyle = "words"',
792
+ template
793
+ ),
794
+ 4
795
+ )
796
+ end
797
+ return TimeDurationUtils.format(seconds, template)
798
+ end
799
+
800
+ type DurationPart = { field: string, count: number }
801
+
802
+ function MetricUnitFormatUtils._breakDuration(seconds: number, maxParts: number): { DurationPart }
803
+ assert(maxParts >= 1, "Bad maxParts")
804
+
805
+ local roundedParts = MetricUnitFormatUtils._breakDurationRoundingSmallestPart(seconds, maxParts)
806
+ local roundedTotal = MetricUnitFormatUtils._totalSeconds(roundedParts)
807
+ if roundedTotal == 0 then
808
+ return roundedParts
809
+ end
810
+
811
+ local partsWithRoundingCarried = MetricUnitFormatUtils._breakDurationRoundingSmallestPart(roundedTotal, maxParts)
812
+ return partsWithRoundingCarried
813
+ end
814
+
815
+ function MetricUnitFormatUtils._totalSeconds(parts: { DurationPart }): number
816
+ local total = 0
817
+ for _, part in parts do
818
+ total += part.count * MetricUnitFormatUtils._durationScale(part.field)
819
+ end
820
+ return total
821
+ end
822
+
823
+ function MetricUnitFormatUtils._breakDurationRoundingSmallestPart(seconds: number, maxParts: number): { DurationPart }
824
+ local smallest = MetricUnitFormatUtils._smallestKeptUnitIndex(seconds, maxParts)
825
+ local counts = MetricUnitFormatUtils._countWholeUnitsRoundingTheSmallest(seconds, smallest)
826
+ local parts: { DurationPart } = {}
827
+ for index = 1, smallest do
828
+ if counts[index] ~= 0 then
829
+ table.insert(
830
+ parts,
831
+ { field = MetricUnitFormatUtils._durationUnitsLargestFirst()[index].field, count = counts[index] }
832
+ )
833
+ end
834
+ end
835
+ if #parts == 0 then
836
+ table.insert(parts, { field = MetricUnitFormatUtils._durationUnitsLargestFirst()[smallest].field, count = 0 })
837
+ end
838
+ return parts
839
+ end
840
+
841
+ function MetricUnitFormatUtils._smallestKeptUnitIndex(seconds: number, maxParts: number): number
842
+ local exactCounts = MetricUnitFormatUtils._countWholeUnitsRoundingTheSmallest(
843
+ seconds,
844
+ #MetricUnitFormatUtils._durationUnitsLargestFirst()
845
+ )
846
+
847
+ local smallest = #MetricUnitFormatUtils._durationUnitsLargestFirst()
848
+ local nonZeroUnitsKept = 0
849
+ for index, count in exactCounts do
850
+ if count ~= 0 then
851
+ nonZeroUnitsKept += 1
852
+ smallest = index
853
+ if nonZeroUnitsKept >= maxParts then
854
+ break
855
+ end
856
+ end
857
+ end
858
+ return smallest
859
+ end
860
+
861
+ function MetricUnitFormatUtils._countWholeUnitsRoundingTheSmallest(seconds: number, smallest: number): { number }
862
+ local counts: { number } = {}
863
+ local remaining = seconds
864
+ for index, unit in MetricUnitFormatUtils._durationUnitsLargestFirst() do
865
+ local count = 0
866
+ if index < smallest then
867
+ count = math.floor(remaining / unit.factor + WHOLE_UNIT_TOLERANCE)
868
+ elseif index == smallest then
869
+ count = math.floor(remaining / unit.factor + 0.5)
870
+ end
871
+ remaining -= count * unit.factor
872
+ counts[index] = count
873
+ end
874
+ return counts
875
+ end
876
+
877
+ function MetricUnitFormatUtils._durationScale(field: string): number
878
+ for _, unit in MetricUnitFormatUtils._durationUnitsLargestFirst() do
879
+ if unit.field == field then
880
+ return unit.factor
881
+ end
882
+ end
883
+ error(string.format("No duration unit %q", field))
884
+ end
885
+
886
+ function MetricUnitFormatUtils._joinNumberToUnitWithNoBreakSpace(text: string, numberText: string): string
887
+ local start, finish = string.find(text, numberText .. " ", 1, true)
888
+ if start and finish then
889
+ return string.sub(text, 1, finish - 1) .. MetricUnitFormatUtils.NO_BREAK_SPACE .. string.sub(text, finish + 1)
890
+ end
891
+ return text
892
+ end
893
+
894
+ function MetricUnitFormatUtils._replaceFirst(text: string, plain: string, replacement: string): string
895
+ local start, finish = string.find(text, plain, 1, true)
896
+ if start and finish then
897
+ return string.sub(text, 1, start - 1) .. replacement .. string.sub(text, finish + 1)
898
+ end
899
+ return text
900
+ end
901
+
902
+ function MetricUnitFormatUtils._durationPhrase(field: string, amount: number, options: MetricUnitFormatOptions): string
903
+ local numberText, rounded, fraction = MetricUnitFormatUtils._formatNumber(amount, options)
904
+
905
+ local overrides = options.durationStrings
906
+ local phrase = if overrides then overrides[field] else nil
907
+ if phrase == nil then
908
+ phrase = (TimeLocalizationUtils.getDurationStringsForLocale(options.locale) :: any)[field]
909
+ end
910
+ assert(phrase, string.format("No duration phrase for %q", field))
911
+
912
+ if type(phrase) == "function" then
913
+ local textWithTostringNumber = phrase(rounded)
914
+ local text = MetricUnitFormatUtils._replaceFirst(textWithTostringNumber, tostring(rounded), numberText)
915
+ return MetricUnitFormatUtils._joinNumberToUnitWithNoBreakSpace(text, numberText)
916
+ end
917
+
918
+ local template = MetricUnitFormatUtils._pickPluralTemplate(phrase, options.locale, rounded, fraction)
919
+ return MetricUnitFormatUtils._joinNumberToUnitWithNoBreakSpace((string.gsub(template, "%%d", function()
920
+ return numberText
921
+ end)), numberText)
922
+ end
923
+
924
+ return table.freeze(MetricUnitFormatUtils)