@fieldia/core 0.20.0 → 0.22.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 (181) hide show
  1. package/README.md +116 -1
  2. package/package.json +1 -1
  3. package/schema/page.schema.json +173 -1
  4. package/src/index.d.mts +2 -2
  5. package/src/index.d.ts +2 -2
  6. package/src/index.js +3 -1
  7. package/src/index.js.map +1 -1
  8. package/src/index.mjs +2 -2
  9. package/src/index.mjs.map +1 -1
  10. package/src/lib/expression/parser.js +4 -4
  11. package/src/lib/expression/parser.js.map +1 -1
  12. package/src/lib/expression/parser.mjs +5 -5
  13. package/src/lib/expression/parser.mjs.map +1 -1
  14. package/src/lib/expression/tokenizer.d.mts +12 -0
  15. package/src/lib/expression/tokenizer.d.ts +12 -0
  16. package/src/lib/expression/tokenizer.js +21 -12
  17. package/src/lib/expression/tokenizer.js.map +1 -1
  18. package/src/lib/expression/tokenizer.mjs +20 -12
  19. package/src/lib/expression/tokenizer.mjs.map +1 -1
  20. package/src/lib/format/check-page.d.mts +3 -2
  21. package/src/lib/format/check-page.d.ts +3 -2
  22. package/src/lib/format/check-page.js +9 -4
  23. package/src/lib/format/check-page.js.map +1 -1
  24. package/src/lib/format/check-page.mjs +10 -5
  25. package/src/lib/format/check-page.mjs.map +1 -1
  26. package/src/lib/format/field.d.mts +101 -0
  27. package/src/lib/format/field.d.ts +101 -0
  28. package/src/lib/format/field.js +25 -2
  29. package/src/lib/format/field.js.map +1 -1
  30. package/src/lib/format/field.mjs +24 -1
  31. package/src/lib/format/field.mjs.map +1 -1
  32. package/src/lib/format/layout.d.mts +142 -17
  33. package/src/lib/format/layout.d.ts +142 -17
  34. package/src/lib/format/layout.js +18 -1
  35. package/src/lib/format/layout.js.map +1 -1
  36. package/src/lib/format/layout.mjs +17 -0
  37. package/src/lib/format/layout.mjs.map +1 -1
  38. package/src/lib/format/layout.types.d.mts +6 -2
  39. package/src/lib/format/layout.types.d.ts +6 -2
  40. package/src/lib/format/page.d.mts +87 -10
  41. package/src/lib/format/page.d.ts +87 -10
  42. package/src/lib/format/page.js +5 -1
  43. package/src/lib/format/page.js.map +1 -1
  44. package/src/lib/format/page.mjs +6 -2
  45. package/src/lib/format/page.mjs.map +1 -1
  46. package/src/lib/format/pattern.d.mts +10 -0
  47. package/src/lib/format/pattern.d.ts +10 -0
  48. package/src/lib/format/pattern.js +33 -0
  49. package/src/lib/format/pattern.js.map +1 -0
  50. package/src/lib/format/pattern.mjs +30 -0
  51. package/src/lib/format/pattern.mjs.map +1 -0
  52. package/src/lib/format/references.d.mts +7 -0
  53. package/src/lib/format/references.d.ts +7 -0
  54. package/src/lib/format/references.js +54 -0
  55. package/src/lib/format/references.js.map +1 -1
  56. package/src/lib/format/references.mjs +54 -0
  57. package/src/lib/format/references.mjs.map +1 -1
  58. package/src/lib/format/translate.js +6 -4
  59. package/src/lib/format/translate.js.map +1 -1
  60. package/src/lib/format/translate.mjs +6 -4
  61. package/src/lib/format/translate.mjs.map +1 -1
  62. package/src/lib/format/upgrade.d.mts +41 -0
  63. package/src/lib/format/upgrade.d.ts +41 -0
  64. package/src/lib/format/upgrade.js +41 -0
  65. package/src/lib/format/upgrade.js.map +1 -0
  66. package/src/lib/format/upgrade.mjs +37 -0
  67. package/src/lib/format/upgrade.mjs.map +1 -0
  68. package/src/lib/format/validate.d.mts +2 -1
  69. package/src/lib/format/validate.d.ts +2 -1
  70. package/src/lib/format/validate.js +4 -2
  71. package/src/lib/format/validate.js.map +1 -1
  72. package/src/lib/format/validate.mjs +4 -2
  73. package/src/lib/format/validate.mjs.map +1 -1
  74. package/src/lib/format/version.d.mts +16 -1
  75. package/src/lib/format/version.d.ts +16 -1
  76. package/src/lib/format/version.js +39 -2
  77. package/src/lib/format/version.js.map +1 -1
  78. package/src/lib/format/version.mjs +36 -1
  79. package/src/lib/format/version.mjs.map +1 -1
  80. package/src/lib/record/check.d.mts +13 -3
  81. package/src/lib/record/check.d.ts +13 -3
  82. package/src/lib/record/check.js +87 -17
  83. package/src/lib/record/check.js.map +1 -1
  84. package/src/lib/record/check.mjs +85 -17
  85. package/src/lib/record/check.mjs.map +1 -1
  86. package/src/lib/record/data-source.d.mts +224 -10
  87. package/src/lib/record/data-source.d.ts +224 -10
  88. package/src/lib/record/data-source.js +29 -0
  89. package/src/lib/record/data-source.js.map +1 -1
  90. package/src/lib/record/data-source.mjs +25 -0
  91. package/src/lib/record/data-source.mjs.map +1 -1
  92. package/src/lib/record/filter.js +14 -2
  93. package/src/lib/record/filter.js.map +1 -1
  94. package/src/lib/record/filter.mjs +14 -2
  95. package/src/lib/record/filter.mjs.map +1 -1
  96. package/src/lib/record/form.d.mts +186 -3
  97. package/src/lib/record/form.d.ts +186 -3
  98. package/src/lib/record/form.js +1036 -57
  99. package/src/lib/record/form.js.map +1 -1
  100. package/src/lib/record/form.mjs +1037 -60
  101. package/src/lib/record/form.mjs.map +1 -1
  102. package/src/lib/record/input-rules.d.mts +277 -0
  103. package/src/lib/record/input-rules.d.ts +277 -0
  104. package/src/lib/record/input-rules.js +724 -0
  105. package/src/lib/record/input-rules.js.map +1 -0
  106. package/src/lib/record/input-rules.mjs +705 -0
  107. package/src/lib/record/input-rules.mjs.map +1 -0
  108. package/src/lib/record/inputs.d.mts +25 -0
  109. package/src/lib/record/inputs.d.ts +25 -0
  110. package/src/lib/record/inputs.js +135 -9
  111. package/src/lib/record/inputs.js.map +1 -1
  112. package/src/lib/record/inputs.mjs +131 -9
  113. package/src/lib/record/inputs.mjs.map +1 -1
  114. package/src/lib/record/locales/ar.js +23 -2
  115. package/src/lib/record/locales/ar.js.map +1 -1
  116. package/src/lib/record/locales/ar.mjs +23 -2
  117. package/src/lib/record/locales/ar.mjs.map +1 -1
  118. package/src/lib/record/locales/de.js +23 -2
  119. package/src/lib/record/locales/de.js.map +1 -1
  120. package/src/lib/record/locales/de.mjs +23 -2
  121. package/src/lib/record/locales/de.mjs.map +1 -1
  122. package/src/lib/record/locales/fr.js +23 -2
  123. package/src/lib/record/locales/fr.js.map +1 -1
  124. package/src/lib/record/locales/fr.mjs +23 -2
  125. package/src/lib/record/locales/fr.mjs.map +1 -1
  126. package/src/lib/record/memory-data-source.d.mts +40 -3
  127. package/src/lib/record/memory-data-source.d.ts +40 -3
  128. package/src/lib/record/memory-data-source.js +171 -23
  129. package/src/lib/record/memory-data-source.js.map +1 -1
  130. package/src/lib/record/memory-data-source.mjs +171 -23
  131. package/src/lib/record/memory-data-source.mjs.map +1 -1
  132. package/src/lib/record/messages.d.mts +43 -0
  133. package/src/lib/record/messages.d.ts +43 -0
  134. package/src/lib/record/messages.js +23 -2
  135. package/src/lib/record/messages.js.map +1 -1
  136. package/src/lib/record/messages.mjs +23 -2
  137. package/src/lib/record/messages.mjs.map +1 -1
  138. package/src/lib/record/rules.js +3 -1
  139. package/src/lib/record/rules.js.map +1 -1
  140. package/src/lib/record/rules.mjs +3 -1
  141. package/src/lib/record/rules.mjs.map +1 -1
  142. package/src/lib/record/run.d.mts +53 -9
  143. package/src/lib/record/run.d.ts +53 -9
  144. package/src/lib/record/run.js +68 -21
  145. package/src/lib/record/run.js.map +1 -1
  146. package/src/lib/record/run.mjs +68 -21
  147. package/src/lib/record/run.mjs.map +1 -1
  148. package/src/lib/record/search.d.mts +8 -1
  149. package/src/lib/record/search.d.ts +8 -1
  150. package/src/lib/record/search.js +28 -8
  151. package/src/lib/record/search.js.map +1 -1
  152. package/src/lib/record/search.mjs +28 -8
  153. package/src/lib/record/search.mjs.map +1 -1
  154. package/src/lib/record/structures.d.mts +8 -3
  155. package/src/lib/record/structures.d.ts +8 -3
  156. package/src/lib/record/structures.js +51 -7
  157. package/src/lib/record/structures.js.map +1 -1
  158. package/src/lib/record/structures.mjs +48 -6
  159. package/src/lib/record/structures.mjs.map +1 -1
  160. package/src/lib/record/upload-to.d.mts +50 -0
  161. package/src/lib/record/upload-to.d.ts +50 -0
  162. package/src/lib/record/upload-to.js +117 -0
  163. package/src/lib/record/upload-to.js.map +1 -0
  164. package/src/lib/record/upload-to.mjs +114 -0
  165. package/src/lib/record/upload-to.mjs.map +1 -0
  166. package/src/lib/record/uploads.d.mts +152 -0
  167. package/src/lib/record/uploads.d.ts +152 -0
  168. package/src/lib/record/uploads.js +345 -0
  169. package/src/lib/record/uploads.js.map +1 -0
  170. package/src/lib/record/uploads.mjs +337 -0
  171. package/src/lib/record/uploads.mjs.map +1 -0
  172. package/src/lib/record/values.d.mts +6 -0
  173. package/src/lib/record/values.d.ts +6 -0
  174. package/src/lib/record/values.js.map +1 -1
  175. package/src/lib/record/values.mjs.map +1 -1
  176. package/src/runtime.d.mts +15 -8
  177. package/src/runtime.d.ts +15 -8
  178. package/src/runtime.js +48 -1
  179. package/src/runtime.js.map +1 -1
  180. package/src/runtime.mjs +12 -5
  181. package/src/runtime.mjs.map +1 -1
@@ -0,0 +1,705 @@
1
+ /**
2
+ * How Fieldia reads what a person types or pastes into a box: one set of
3
+ * readers for every box — a form's fields, a table's cells, a list's filters,
4
+ * the designer's settings — so a number, a duration, a date or a word is read
5
+ * the same way wherever it is typed. Pure: no DOM, no form.
6
+ *
7
+ * People in Egypt and the Gulf type in Arabic too: Arabic-Indic digits
8
+ * (٠١٢…), Persian ones (۰۱۲…), the Arabic decimal separator (٫) and its
9
+ * thousands separator (٬), and a French or German decimal comma are read,
10
+ * never refused. Invisible direction marks, which Arabic text carries and
11
+ * copying brings along, are never part of what is read.
12
+ */
13
+ /** Invisible direction marks: LRM, RLM, ALM, the embeddings and overrides, the isolates, and a byte-order mark. */
14
+ const MARKS = /[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069\ufeff]/g;
15
+ const MARKS_AT_ENDS = /^[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069\ufeff]+|[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069\ufeff]+$/g;
16
+ /** Invisible direction marks taken out: everywhere (what is structured), or only at the ends (any one line of text). */
17
+ export function stripMarks(text, where = 'all') {
18
+ return text.replace(where === 'all' ? MARKS : MARKS_AT_ENDS, '');
19
+ }
20
+ /** Arabic-Indic (٠-٩), Persian (۰-۹) and fullwidth (0-9) digits as 0-9; every other character as it is. */
21
+ export function foldDigits(text) {
22
+ return text.replace(/[\u0660-\u0669\u06f0-\u06f9\uff10-\uff19]/g, (d) => {
23
+ const code = d.charCodeAt(0);
24
+ return String(code - (code >= 0xff10 ? 0xff10 : code >= 0x06f0 ? 0x06f0 : 0x0660));
25
+ });
26
+ }
27
+ /** Line breaks as spaces, as one line of text keeps them; a password drops them instead. */
28
+ export function oneLine(text, options = {}) {
29
+ return text.replace(/\r\n|[\r\n\u2028\u2029]/g, options.password ? '' : ' ');
30
+ }
31
+ /** How many characters a text has, as a server counts them: by code point, so an emoji is one. */
32
+ export function charCount(text) {
33
+ let n = 0;
34
+ for (const _ of text)
35
+ n++;
36
+ return n;
37
+ }
38
+ /**
39
+ * The Arabic letters the readers fold or read, made from their code points: the one-tag script carries no
40
+ * Arabic words (tools/script-bundle.mjs checks that none got past the languages' add-ons), and these are none.
41
+ */
42
+ const letter = (...codes) => String.fromCharCode(...codes);
43
+ const ARABIC = {
44
+ /** أ إ آ ٱ, as ا. */
45
+ alefs: new RegExp(`[${letter(0x623, 0x625, 0x622, 0x671)}]`, 'g'),
46
+ alef: letter(0x627),
47
+ /** ة as ه, ى as ي. */
48
+ tehMarbuta: new RegExp(letter(0x629), 'g'),
49
+ heh: letter(0x647),
50
+ alefMaksura: new RegExp(letter(0x649), 'g'),
51
+ yeh: letter(0x64a),
52
+ /** The tatweel and the short vowels. */
53
+ marks: new RegExp(`[${letter(0x640)}${letter(0x64b)}-${letter(0x65f)}${letter(0x670)}]`, 'g'),
54
+ /** ص and م: a time's am and pm. */
55
+ am: letter(0x635),
56
+ pm: letter(0x645),
57
+ };
58
+ /** am or pm after a time, in English or Arabic. */
59
+ const HALF = `[aApP]\\.?\\s*[mM]\\.?|[${ARABIC.am}${ARABIC.pm}]`;
60
+ /** A time after a date, set apart by a space or a T: "14:30", "14h30", "2:30 pm", "2 pm", "٢:٣٠ م". */
61
+ const TIME_AFTER_DATE = new RegExp(`^(.*?\\d)(?:\\s+|T)(\\d{1,2}\\s*[:hH.]\\s*\\d{1,2}(?::\\d{2})?\\s*(?:${HALF})?|\\d{1,2}\\s*(?:${HALF}))$`);
62
+ /** A time of day: hours, minutes after a colon, a point or an h, seconds, am or pm. */
63
+ const TIME = new RegExp(`^(\\d{1,2})(?:\\s*[:.hH]\\s*(\\d{1,2})?)?(?::(\\d{2}))?\\s*(${HALF})?$`);
64
+ /**
65
+ * Words folded for finding: case, Arabic letter forms (أ إ آ ٱ as ا, ة as ه,
66
+ * ى as ي, the tatweel and short vowels dropped), digits in any script as 0-9,
67
+ * and no direction marks — so "أحمد" finds "احمد" and "١٢" finds "12".
68
+ */
69
+ export function foldForSearch(text) {
70
+ return foldDigits(stripMarks(text))
71
+ .toLowerCase()
72
+ .replace(ARABIC.alefs, ARABIC.alef)
73
+ .replace(ARABIC.tehMarbuta, ARABIC.heh)
74
+ .replace(ARABIC.alefMaksura, ARABIC.yeh)
75
+ .replace(ARABIC.marks, '');
76
+ }
77
+ const marksByLocale = new Map();
78
+ /** The marks a language writes numbers with, in Latin digits: en "." and ",", de "," and ".", fr "," and a narrow space. */
79
+ export function numberMarks(locale) {
80
+ let found = marksByLocale.get(locale);
81
+ if (!found) {
82
+ let parts;
83
+ try {
84
+ parts = new Intl.NumberFormat(locale, { numberingSystem: 'latn' }).formatToParts(12345.6);
85
+ }
86
+ catch {
87
+ parts = [];
88
+ }
89
+ found = { decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.', group: parts.find((p) => p.type === 'group')?.value ?? ',' };
90
+ marksByLocale.set(locale, found);
91
+ }
92
+ return found;
93
+ }
94
+ const MINUS = '-\u2212\ufe63\uff0d';
95
+ const IS_MINUS = new RegExp(`[${MINUS}]`);
96
+ const LINE_BREAKS = /[\t\n\r\v\f\u2028\u2029]/;
97
+ const GROUP_SPACE = /[ \u00a0\u2007\u2009\u202f]/;
98
+ /** Fullwidth marks a Japanese or Chinese keyboard types, as their plain kind. */
99
+ const fullwidth = (text) => text.replace(/[\uff0b\uff0c\uff0e\uff05]/g, (c) => ({ '\uff0b': '+', '\uff0c': ',', '\uff0e': '.', '\uff05': '%' })[c]);
100
+ /** Text as the readers see it: no direction marks, digits and fullwidth marks folded. */
101
+ const folded = (text) => fullwidth(foldDigits(stripMarks(text)));
102
+ function kindOf(char) {
103
+ if (char === '.')
104
+ return 'dot';
105
+ if (char === ',')
106
+ return 'comma';
107
+ if (char === '\u060c')
108
+ return 'arcomma';
109
+ if (char === '\u066b')
110
+ return 'ardecimal';
111
+ if (char === '\u066c')
112
+ return 'argroup';
113
+ return GROUP_SPACE.test(char) ? 'space' : null;
114
+ }
115
+ /** The body's marks, and which is the decimal mark, by the reading rules (see readNumber); `ungrouped`, its one mark. */
116
+ function takeApart(body, page, ungrouped = false) {
117
+ const marks = [];
118
+ let run = '';
119
+ for (const char of body) {
120
+ if (char >= '0' && char <= '9') {
121
+ run += char;
122
+ continue;
123
+ }
124
+ const kind = kindOf(char);
125
+ if (!kind)
126
+ return { marks, tail: run, decimal: -1, ambiguous: false, problem: 'numbersOnly' };
127
+ marks.push({ kind, char, before: run });
128
+ run = '';
129
+ }
130
+ const tail = run;
131
+ const after = (i) => (i + 1 < marks.length ? marks[i + 1].before : tail);
132
+ const of = (...kinds) => marks.map((m, i) => (kinds.includes(m.kind) ? i : -1)).filter((i) => i >= 0);
133
+ const done = (decimal, ambiguous = false, problem) => ({ marks, tail, decimal, ambiguous, ...(problem ? { problem } : {}) });
134
+ if (ungrouped) {
135
+ // Never grouped: a space or ٬ is no part of it, and a second mark is a second decimal mark.
136
+ if (marks.some((m) => m.kind === 'space' || m.kind === 'argroup'))
137
+ return done(-1, false, 'numbersOnly');
138
+ return marks.length > 1 ? done(1, false, 'oneDecimalMark') : done(marks.length - 1);
139
+ }
140
+ const arabic = of('ardecimal');
141
+ if (arabic.length > 1)
142
+ return done(arabic[1], false, 'oneDecimalMark');
143
+ if (arabic.length === 1)
144
+ return done(arabic[0]);
145
+ const dots = of('dot');
146
+ const commas = of('comma', 'arcomma');
147
+ if (dots.length && commas.length) {
148
+ // Both: the last one is the decimal mark, and the other groups.
149
+ const last = Math.max(dots[dots.length - 1], commas[commas.length - 1]);
150
+ const same = marks[last].kind === 'dot' ? dots : commas;
151
+ return done(last, false, same.length > 1 ? 'oneDecimalMark' : undefined);
152
+ }
153
+ const one = dots.length ? dots : commas;
154
+ // One kind repeated groups; none at all, only spaces or ٬, groups too.
155
+ if (one.length !== 1)
156
+ return done(-1);
157
+ const at = one[0];
158
+ const mark = marks[at];
159
+ if (mark.kind === 'arcomma' || mark.char === page.decimal)
160
+ return done(at);
161
+ if (mark.char === page.group) {
162
+ const following = after(at);
163
+ // The page's grouping mark groups only with exactly three digits after it and one to three before, never led by
164
+ // a 0: no one groups "0,500", which is a decimal comma, a half.
165
+ const leads = mark.before.length >= 1 && mark.before.length <= 3 && !mark.before.startsWith('0');
166
+ if (following.length === 3 && leads)
167
+ return done(-1);
168
+ // Fewer than three after it yet, where it could still group: typing may be on its way to "1,500".
169
+ const couldGroup = following.length < 3 && leads && at === marks.length - 1;
170
+ return done(at, couldGroup);
171
+ }
172
+ // A mark the page does not use is a decimal mark.
173
+ return done(at);
174
+ }
175
+ /** Whether the groups before the decimal mark (all of them, without one) are groups of three, or could still become them. */
176
+ function groupsProblem(body, prefix) {
177
+ const { marks, tail, decimal } = body;
178
+ const groupMarks = decimal === -1 ? marks : marks.slice(0, decimal);
179
+ // Groups never come after the decimal mark.
180
+ if (decimal !== -1 && decimal < marks.length - 1)
181
+ return 'groupsOfThree';
182
+ if (!groupMarks.length)
183
+ return null;
184
+ const runs = [...groupMarks.map((m) => m.before), decimal === -1 ? tail : marks[decimal].before];
185
+ // The first group has one to three digits, and no one writes a grouped number that starts with 0 ("0,500,000").
186
+ if (runs[0].length < 1 || runs[0].length > 3 || runs[0].startsWith('0'))
187
+ return 'groupsOfThree';
188
+ for (let i = 1; i < runs.length; i++) {
189
+ const last = i === runs.length - 1;
190
+ const len = runs[i].length;
191
+ // The last group, with nothing after it, may still be on its way to three digits.
192
+ if (last && decimal === -1 && prefix ? len > 3 : len !== 3)
193
+ return 'groupsOfThree';
194
+ }
195
+ return null;
196
+ }
197
+ /** The sign taken off: one leading minus (or plus, dropped); a minus anywhere else is a problem. */
198
+ function takeSign(text, options) {
199
+ let t = text.trim();
200
+ if (options.percent)
201
+ t = t.replace(/\s*[%\u066a]$/, '');
202
+ let negative = false;
203
+ if (t && (IS_MINUS.test(t[0]) || t[0] === '+')) {
204
+ negative = t[0] !== '+';
205
+ t = t.slice(1).replace(/^\s+/, '');
206
+ }
207
+ if (IS_MINUS.test(t))
208
+ return { negative, body: t, problem: 'minusFirst' };
209
+ if (t.includes('+'))
210
+ return { negative, body: t, problem: 'numbersOnly' };
211
+ return { negative, body: t };
212
+ }
213
+ /**
214
+ * Typed or pasted text as a number, in the page's language (`locale`).
215
+ *
216
+ * Digits in Latin, Arabic-Indic, Persian and fullwidth script; one leading
217
+ * minus (also \u2212 U+2212) or plus; spaces, and the marks . , ، ٫ ٬. Which mark
218
+ * is the decimal one:
219
+ * - both . and , : the last; the other groups;
220
+ * - one kind repeated: it groups;
221
+ * - one mark once: the page's decimal mark, ٫ or ، is decimal; the page's
222
+ * grouping mark groups only with exactly three digits after it and one to
223
+ * three before; a mark the page does not use is decimal;
224
+ * - spaces and ٬ only group.
225
+ * Groups run one to three digits, then threes, and never come after the
226
+ * decimal mark, and the first group is never led by a 0. So on en and ar
227
+ * pages 1,500 is 1500 and 1,5 is 1.5; on a de page 1.500 is 1500 and 1.5 is
228
+ * 1.5; anywhere 0,500 and 0.500 are a half, 1 234,56 is 1234.56, and 12 34
229
+ * does not read. Nothing reads letters, a tab or a line break.
230
+ */
231
+ export function readNumber(text, locale, options = {}) {
232
+ const t = folded(text);
233
+ if (LINE_BREAKS.test(t.trim()))
234
+ return { ok: false, problem: 'severalValues' };
235
+ if (!t.trim())
236
+ return { ok: true, value: null, plain: '', places: 0, digits: 0, significant: 0 };
237
+ const sign = takeSign(t, options);
238
+ if (sign.problem)
239
+ return { ok: false, problem: sign.problem };
240
+ if (!sign.body)
241
+ return { ok: false, problem: 'unfinished' };
242
+ const page = numberMarks(locale);
243
+ const body = takeApart(sign.body, page, options.ungrouped);
244
+ if (body.problem)
245
+ return { ok: false, problem: body.problem };
246
+ const { marks, tail, decimal } = body;
247
+ // A grouping mark last ("1,", "1.234."): the number is still being written.
248
+ if (marks.length && !tail && (decimal === -1 || body.ambiguous || marks[decimal].char === page.group))
249
+ return { ok: false, problem: 'unfinished' };
250
+ const groups = groupsProblem(body, false);
251
+ if (groups)
252
+ return { ok: false, problem: groups };
253
+ const whole = (decimal === -1 ? [...marks.map((m) => m.before), tail] : [...marks.slice(0, decimal + 1).map((m) => m.before)]).join('');
254
+ const fraction = decimal === -1 ? '' : tail;
255
+ if (!whole && !fraction)
256
+ return { ok: false, problem: 'unfinished' };
257
+ const value = Number(`${whole || '0'}.${fraction || '0'}`) * (sign.negative ? -1 : 1);
258
+ const digits = whole.replace(/^0+/, '').length + fraction.length;
259
+ const significant = (whole + fraction).replace(/^0+/, '').length;
260
+ const plain = `${sign.negative && value !== 0 ? '-' : ''}${whole || '0'}${fraction ? page.decimal + fraction : ''}`;
261
+ return { ok: true, value: value === 0 ? 0 : value, plain, places: fraction.length, digits, significant };
262
+ }
263
+ /** The most significant digits a number keeps exactly. */
264
+ export const MOST_DIGITS = 15;
265
+ /**
266
+ * Whether the decimal mark a text reads with may still turn out to group as
267
+ * more is typed: a lone . or , (or ،), last, with one to three digits before
268
+ * it (never led by a 0) and at most three after. "1.234" on an English page
269
+ * is on its way to "1.234,56" as much as to 1.234, and "1,5" on a German one
270
+ * to "1,500.00"; only the next mark says which.
271
+ */
272
+ function mayStillGroup(body) {
273
+ const { marks, decimal } = body;
274
+ if (decimal === -1 || decimal !== marks.length - 1)
275
+ return false;
276
+ const pointOrComma = (m) => m.kind === 'dot' || m.kind === 'comma' || m.kind === 'arcomma';
277
+ if (!pointOrComma(marks[decimal]) || marks.some((m, i) => i !== decimal && pointOrComma(m)))
278
+ return false;
279
+ return groupsProblem({ ...body, decimal: -1 }, true) === null;
280
+ }
281
+ /**
282
+ * Whether the text a box would hold after a keystroke may stand: null when it
283
+ * may, or why not. Only what no more typing can make right is refused, so
284
+ * every way of writing a number the field takes can be typed key by key:
285
+ * letters, emoji and other symbols; a minus that is not first, or any minus
286
+ * where the least is 0 or more; a decimal mark that can only be one followed
287
+ * by a digit other than 0 in a whole number, or by more places than the
288
+ * field's (trailing zeros are no places: 12.50 has one, 12.0 none); a second
289
+ * decimal mark; a digit past `digits` or past 15 significant ones; and
290
+ * grouping that can never be right ("1.2." on a German page). Text on its way
291
+ * to a number ("-", "1,", "12 3", "1.234" on its way to "1.234,56") may stand;
292
+ * what it turns out to be, and min and max, are said as the box is left.
293
+ */
294
+ export function couldBeNumber(text, locale, limits = {}) {
295
+ const t = folded(text);
296
+ if (LINE_BREAKS.test(t))
297
+ return { problem: 'severalValues' };
298
+ const sign = takeSign(t, limits);
299
+ const atLeastZero = limits.min !== undefined && limits.min >= 0;
300
+ const minus = (sign.negative || IS_MINUS.test(sign.body)) && atLeastZero;
301
+ if (minus)
302
+ return { problem: 'min', min: limits.min };
303
+ if (sign.problem)
304
+ return { problem: sign.problem };
305
+ if (limits.percent && /[%\u066a]/.test(sign.body))
306
+ return { problem: 'numbersOnly' };
307
+ if (limits.duration)
308
+ return couldBeDuration(sign.body);
309
+ const page = numberMarks(locale);
310
+ const body = takeApart(sign.body, page, limits.ungrouped);
311
+ if (body.problem === 'numbersOnly')
312
+ return { problem: 'numbersOnly' };
313
+ if (body.problem === 'oneDecimalMark')
314
+ return { problem: 'oneDecimalMark' };
315
+ // Two marks side by side never make a number ("1,,5", "1 ,5").
316
+ if (body.marks.some((m, i) => i > 0 && m.before === ''))
317
+ return { problem: 'groupsOfThree' };
318
+ if (groupsProblem(body, true)) {
319
+ // The page's decimal mark (or ٫) twice where they cannot group: a second decimal mark ("1.2." on an English page).
320
+ const decimals = [...sign.body].filter((c) => c === page.decimal || c === '\u066b').length;
321
+ return { problem: decimals > 1 ? 'oneDecimalMark' : 'groupsOfThree' };
322
+ }
323
+ const certain = body.decimal !== -1 && !body.ambiguous && !mayStillGroup(body);
324
+ const fraction = body.decimal === -1 ? '' : body.tail;
325
+ // Trailing zeros say no more than the number without them: 12.50 has one place, 1,200.00 is whole.
326
+ const places = fraction.replace(/0+$/, '');
327
+ if (certain && limits.integer && places)
328
+ return { problem: 'integer' };
329
+ if (certain && limits.places !== undefined && places.length > limits.places)
330
+ return { problem: 'decimals', places: limits.places };
331
+ const all = [...sign.body].filter((c) => c >= '0' && c <= '9').join('');
332
+ const whole = body.decimal === -1 ? all : all.slice(0, all.length - fraction.length);
333
+ const significant = (whole + places).replace(/^0+/, '').length;
334
+ const digits = whole.replace(/^0+/, '').length + places.length;
335
+ const most = Math.min(MOST_DIGITS, limits.digits ?? MOST_DIGITS);
336
+ if (significant > MOST_DIGITS || digits > most)
337
+ return { problem: 'digits', n: most };
338
+ return null;
339
+ }
340
+ /**
341
+ * Why a number read cannot be held by a field of these limits, or null: a
342
+ * decimal in a whole number, places past the field's, digits past its own or
343
+ * past 15. Not its least and most, which check says. For text a box is left
344
+ * with: as it was typed, "12.345" in a box of two places could not be told
345
+ * from "12.345,67" on its way.
346
+ */
347
+ export function numberMisfit(value, limits) {
348
+ if (limits.duration || !Number.isFinite(value))
349
+ return null;
350
+ const places = placesOf(value);
351
+ if (limits.integer && !Number.isInteger(value))
352
+ return { problem: 'integer' };
353
+ if (limits.places !== undefined && places > limits.places)
354
+ return { problem: 'decimals', places: limits.places };
355
+ const integerPart = Math.trunc(Math.abs(value));
356
+ const whole = integerPart === 0 ? 0 : integerPart < 1e21 ? String(integerPart).length : 22;
357
+ const most = Math.min(MOST_DIGITS, limits.digits ?? MOST_DIGITS);
358
+ if (whole > MOST_DIGITS || whole + places > most)
359
+ return { problem: 'digits', n: most };
360
+ return null;
361
+ }
362
+ /** An hours box's keystroke: digits, the marks, `:` and `h`, the colon and the h each once (a second colon only for ":00" seconds). */
363
+ function couldBeDuration(body) {
364
+ if (/[^0-9.,\u060c\u066b\u066c:hH \u00a0\u202f]/.test(body))
365
+ return { problem: 'numbersOnly' };
366
+ if ((body.match(/[hH]/g) ?? []).length > 1 || (body.match(/:/g) ?? []).length > 2)
367
+ return { problem: 'numbersOnly' };
368
+ return null;
369
+ }
370
+ const currencyForms = new Map();
371
+ /** Every way a currency is written beside an amount: its symbols, narrow and wide, its code, in the page's language, English and Arabic. */
372
+ function formsOf(currency, locale) {
373
+ const key = `${currency}:${locale}`;
374
+ let found = currencyForms.get(key);
375
+ if (!found) {
376
+ const forms = new Set([currency]);
377
+ for (const language of new Set([locale, 'en', 'ar'])) {
378
+ for (const currencyDisplay of ['symbol', 'narrowSymbol', 'code']) {
379
+ try {
380
+ const part = new Intl.NumberFormat(language, { style: 'currency', currency, currencyDisplay }).formatToParts(1).find((p) => p.type === 'currency');
381
+ if (part)
382
+ forms.add(stripMarks(part.value).trim());
383
+ }
384
+ catch {
385
+ // A name that is no currency code: only its own words.
386
+ }
387
+ }
388
+ }
389
+ found = [...forms].filter(Boolean).sort((a, b) => b.length - a.length);
390
+ currencyForms.set(key, found);
391
+ }
392
+ return found;
393
+ }
394
+ /**
395
+ * Pasted (or dropped) text as the plain number a box takes, in the page's
396
+ * way: direction marks, the box's own currency (its symbol, narrow symbol,
397
+ * code or Arabic symbol), grouping, % and spaces taken out, and an
398
+ * accounting negative read — (1,200.00) and 1,200- are -1200. Another
399
+ * currency is named; a tab or a line break inside is several values; a
400
+ * single line break at the end, as a copied cell brings, is let go.
401
+ */
402
+ export function cleanPastedNumber(text, locale, options = {}) {
403
+ let t = folded(text).replace(/(\r\n|[\r\n])$/, '');
404
+ if (LINE_BREAKS.test(t.trim()))
405
+ return { ok: false, problem: 'severalValues' };
406
+ t = t.trim();
407
+ if (options.currency)
408
+ for (const form of formsOf(options.currency, locale))
409
+ t = t.split(form).join(' ');
410
+ t = t.replace(/[%\u066a]/g, ' ').trim();
411
+ const other = /[\p{Sc}]|[A-Za-z]{3}|[\u0600-\u06ff]+\.[\u0600-\u06ff.]*/u.exec(t.replace(/[\u0660-\u0669]/g, ''));
412
+ if (other) {
413
+ const token = /[\p{L}\p{Sc}][\p{L}\p{Sc}.]*/u.exec(t)?.[0] ?? other[0];
414
+ if (/\p{Sc}/u.test(token) || /^[A-Z]{3}$/.test(token) || /[\u0600-\u06ff]/.test(token))
415
+ return { ok: false, problem: 'otherCurrency', currency: token };
416
+ return { ok: false, problem: 'notANumber' };
417
+ }
418
+ // An accounting negative: in brackets, or with its minus after it.
419
+ const bracketed = /^\(\s*([^()]*?)\s*\)$/.exec(t);
420
+ if (bracketed)
421
+ t = `-${bracketed[1]}`;
422
+ const trailing = new RegExp(`^([^${MINUS}]*\\d)\\s*[${MINUS}]$`).exec(t);
423
+ if (trailing)
424
+ t = `-${trailing[1]}`;
425
+ // A sign set apart from the amount by the currency taken out of it ("- 1,500").
426
+ t = t.replace(new RegExp(`^([${MINUS}+])\\s+`), '$1');
427
+ const read = readNumber(t, locale, { ungrouped: options.ungrouped });
428
+ if (!read.ok || read.value === null)
429
+ return { ok: false, problem: read.ok ? 'notANumber' : read.problem === 'severalValues' ? 'severalValues' : 'notANumber' };
430
+ return { ok: true, text: read.plain, value: read.value };
431
+ }
432
+ /**
433
+ * A minute written with one digit, `digit`, after `mark` (a colon, an h or a point): the minutes it could be, earliest
434
+ * first, or null when it can only be the one written. After a colon or an h, "3" is :03 or :30, while "0" and "6" to
435
+ * "9" can only be :00 and :06 to :09 (60 and more are no minutes). After a point, as "14.5", hours with a decimal are
436
+ * as likely as a clock's minutes: "14.5" is 14:05, half past two or 14:50, "14.7" 14:07 or 14:42; only "14.0" is one.
437
+ */
438
+ function oneDigitMinute(digit, mark) {
439
+ const m = Number(digit);
440
+ if (m === 0)
441
+ return null;
442
+ const minutes = new Set([m, ...(m <= 5 ? [m * 10] : []), ...(mark === '.' ? [m * 6] : [])]);
443
+ return minutes.size > 1 ? [...minutes].sort((a, b) => a - b).map(pad2) : null;
444
+ }
445
+ /**
446
+ * Typed hours: "6:30", "06:30", "6.5" (or "6,5"), "6h30", "6h", "6:30:00"
447
+ * (seconds only when :00), Arabic digits too; a leading minus. Minutes past 59
448
+ * do not read ('minutes'), nor do "6 30" or other shapes ('duration'); "6:"
449
+ * and "-" are on their way ('unfinished'). Minutes of one digit that could be
450
+ * more than one ("6:3": 6:03 or 6:30) are never read as one ('minuteDigits',
451
+ * naming each); "6:7" can only be 6:07.
452
+ */
453
+ export function readDuration(text, locale) {
454
+ const t = folded(text).trim();
455
+ if (LINE_BREAKS.test(t))
456
+ return { ok: false, problem: 'severalValues' };
457
+ if (!t)
458
+ return { ok: true, value: null };
459
+ const sign = takeSign(t, {});
460
+ if (sign.problem)
461
+ return { ok: false, problem: sign.problem === 'minusFirst' ? 'minusFirst' : 'duration' };
462
+ const body = sign.body;
463
+ const signed = (hours) => (sign.negative && hours !== 0 ? -hours : hours);
464
+ if (!body || /^\d+\s*:$/.test(body) || body === ':')
465
+ return { ok: false, problem: 'unfinished' };
466
+ const clock = /^(\d+)\s*(?::|[hH])\s*(\d{1,2})?(?::(\d{1,2}))?$/.exec(body);
467
+ if (clock) {
468
+ // An h alone is whole hours ("6h").
469
+ const [, h, m = '0', s] = clock;
470
+ // "6:3" could be 6:03 or 6:30: neither is taken in silence.
471
+ const either = m.length === 1 ? oneDigitMinute(m, ':') : null;
472
+ if (either) {
473
+ const minus = sign.negative ? '-' : '';
474
+ return { ok: false, problem: 'minuteDigits', times: either.map((minutes) => `${minus}${Number(h)}:${minutes}`) };
475
+ }
476
+ if (Number(m) > 59)
477
+ return { ok: false, problem: 'minutes' };
478
+ if (s !== undefined && Number(s) !== 0)
479
+ return { ok: false, problem: 'duration' };
480
+ return { ok: true, value: signed(Number(h) + Number(m) / 60) };
481
+ }
482
+ if (/[:hH]/.test(body))
483
+ return { ok: false, problem: 'duration' };
484
+ // Hours as a number, in the page's way: "6.5", "6,5".
485
+ const read = readNumber(body, locale);
486
+ if (read.ok)
487
+ return { ok: true, value: read.value === null ? null : signed(read.value) };
488
+ if (read.problem === 'unfinished')
489
+ return { ok: false, problem: 'unfinished' };
490
+ return { ok: false, problem: read.problem === 'numbersOnly' ? 'numbersOnly' : 'duration' };
491
+ }
492
+ const daysIn = (year, month) => [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
493
+ const pad2 = (n) => String(n).padStart(2, '0');
494
+ /** A moment as the local day and time it is, as the readers give a date and time: yyyy-mm-ddTHH:MM. */
495
+ const localOf = (d) => `${String(d.getFullYear()).padStart(4, '0')}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
496
+ /** An instant as a server or a spreadsheet writes one, with its zone: 2026-10-15T14:30:00Z, 2026-10-15T16:30:00.000+02:00. */
497
+ const INSTANT = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)$/i;
498
+ /** The languages whose month names a date may be written with, whatever the page's: the four Fieldia speaks. */
499
+ const MONTH_LANGUAGES = ['en-GB', 'en-US', 'ar', 'fr', 'de'];
500
+ const monthsByLocale = new Map();
501
+ /** A month's name as it is looked up: folded as a search folds it, its dot ("oct.", "Okt.") let go. */
502
+ const monthKey = (name) => foldForSearch(name).replace(/\.+$/, '').trim();
503
+ /**
504
+ * Every name a month is written with — long and short, alone and inside a date, as Intl writes them — in English,
505
+ * Arabic, French and German, and the page's own language: by its folded name, its number. Made from Intl, so no
506
+ * language's words are carried in the code (the one-tag script carries none).
507
+ */
508
+ function monthNames(locale) {
509
+ const key = locale ?? '';
510
+ let found = monthsByLocale.get(key);
511
+ if (found)
512
+ return found;
513
+ found = new Map();
514
+ for (const language of new Set([...MONTH_LANGUAGES, ...(locale ? [locale] : [])])) {
515
+ for (const month of ['long', 'short']) {
516
+ let alone;
517
+ let inDate;
518
+ try {
519
+ alone = new Intl.DateTimeFormat(language, { month, calendar: 'gregory' });
520
+ inDate = new Intl.DateTimeFormat(language, { day: 'numeric', month, year: 'numeric', calendar: 'gregory' });
521
+ }
522
+ catch {
523
+ continue; // a language Intl does not know: its names are not read
524
+ }
525
+ for (let m = 0; m < 12; m++) {
526
+ const day = new Date(2000, m, 15);
527
+ for (const name of [alone.format(day), inDate.formatToParts(day).find((part) => part.type === 'month')?.value ?? '']) {
528
+ const folded = monthKey(name);
529
+ if (folded && !/\d/.test(folded) && !found.has(folded))
530
+ found.set(folded, m + 1);
531
+ }
532
+ }
533
+ }
534
+ }
535
+ monthsByLocale.set(key, found);
536
+ return found;
537
+ }
538
+ /** Whether `word` is how a month's name begins, in any of the languages its names are read in: typing it is on its way. */
539
+ export function couldBeMonth(word, locale) {
540
+ const wanted = monthKey(word);
541
+ if (!wanted)
542
+ return false;
543
+ for (const name of monthNames(locale).keys())
544
+ if (name.startsWith(wanted))
545
+ return true;
546
+ return false;
547
+ }
548
+ /**
549
+ * A typed date, in the page's order (`order`, day/month/year unless told):
550
+ * separators / . - or a space; ISO yyyy-mm-dd always; Arabic and Persian
551
+ * digits; a year of four digits, from 1000 — one of two digits could be either
552
+ * century, so it is never read as one ('shortYear', naming both). Its month
553
+ * may be a word, as Fieldia writes a date at rest — "15 Oct 2026", "15
554
+ * أكتوبر 2026", "15 oct. 2026", "15. Okt. 2026", "Oct 15, 2026" — in
555
+ * English, Arabic, French, German or the page's language (`locale`); the
556
+ * numbers beside a month in words are its day and its year of four digits,
557
+ * in either order. With `time`, a time may follow ("13/10/2026 14:30", "…,
558
+ * 2:30 pm", "… ٢:٣٠ م"), and the value is yyyy-mm-ddTHH:MM. An instant with
559
+ * its zone (2026-10-15T14:30:00Z) is the local day and time it is — with
560
+ * `time`, its seconds :00, never dropped. Unfinished or impossible dates say
561
+ * why (31/02 → 'noSuchDay' with the days that month has).
562
+ */
563
+ export function readDate(text, options = {}) {
564
+ let t = folded(text).trim();
565
+ if (!t)
566
+ return { ok: true, value: null };
567
+ const instant = INSTANT.exec(t);
568
+ if (instant) {
569
+ const [, y, mo, d, h, mi, seconds = '0', fraction = '0', zone] = instant;
570
+ // A time is kept to the minute: seconds are never dropped in silence. A date's box takes the instant's day alone.
571
+ if (options.time && (Number(seconds) !== 0 || Number(fraction) !== 0))
572
+ return { ok: false, problem: 'time' };
573
+ const offset = /^z$/i.test(zone) ? 'Z' : zone.length === 3 ? `${zone}:00` : `${zone.slice(0, 3)}:${zone.slice(-2)}`;
574
+ const at = new Date(`${y}-${mo}-${d}T${h}:${mi}:00${offset}`);
575
+ if (Number.isNaN(at.getTime()))
576
+ return { ok: false, problem: 'unfinished' };
577
+ const local = localOf(at);
578
+ return options.time ? { ok: true, value: local } : { ok: true, value: local.slice(0, 10), timeLeftOut: local.slice(11) };
579
+ }
580
+ // A comma before a space sets a date's parts apart ("Oct 15, 2026") or its time ("15 Oct 2026, 14:30"): no part of either.
581
+ t = t.replace(/\s*[,\u060c](?=\s)/g, '');
582
+ let datePart = t;
583
+ let timePart = '';
584
+ if (options.time) {
585
+ const split = TIME_AFTER_DATE.exec(t);
586
+ if (split)
587
+ [datePart, timePart] = [split[1], split[2]];
588
+ }
589
+ const pieces = datePart.split(/\s*[/.\-\s]\s*/).filter((p, i, all) => p !== '' || i < all.length - 1);
590
+ if (pieces.length < 3 || pieces.some((p) => p === ''))
591
+ return { ok: false, problem: 'unfinished' };
592
+ if (pieces.length > 3)
593
+ return { ok: false, problem: 'unfinished' };
594
+ let yearText;
595
+ let monthText;
596
+ let dayText;
597
+ let month;
598
+ const named = pieces.findIndex((p) => !/^\d*$/.test(p));
599
+ if (named >= 0) {
600
+ // A month in words: the other two are its day and its year, the year the one of four digits (else the last).
601
+ const numbers = pieces.filter((_, i) => i !== named);
602
+ const found = monthNames(options.locale).get(monthKey(pieces[named]));
603
+ if (!found || numbers.some((p) => !/^\d+$/.test(p)))
604
+ return { ok: false, problem: 'unfinished' };
605
+ const at = numbers[0].length > 2 && numbers[1].length <= 2 ? 0 : 1;
606
+ [yearText, dayText] = [numbers[at], numbers[1 - at]];
607
+ monthText = String(found);
608
+ month = found;
609
+ }
610
+ else {
611
+ // ISO, whatever the page's order: a four-digit year first.
612
+ const order = pieces[0].length === 4 ? 'ymd' : (options.order ?? 'dmy');
613
+ [yearText, monthText, dayText] = [pieces[order.indexOf('y')], pieces[order.indexOf('m')], pieces[order.indexOf('d')]];
614
+ month = Number(monthText);
615
+ }
616
+ if (yearText.length === 2)
617
+ return { ok: false, problem: 'shortYear', short: yearText, early: 1900 + Number(yearText), late: 2000 + Number(yearText) };
618
+ if (yearText.length !== 4)
619
+ return { ok: false, problem: 'yearDigits' };
620
+ const year = Number(yearText);
621
+ // Four digits from 1000: year 2, which a box half typed once made, is no year people write.
622
+ if (year < 1000)
623
+ return { ok: false, problem: 'yearDigits' };
624
+ const day = Number(dayText);
625
+ if (monthText.length > 2 || month < 1 || month > 12)
626
+ return { ok: false, problem: 'noSuchMonth' };
627
+ const days = daysIn(year, month);
628
+ if (dayText.length > 2 || day < 1 || day > days)
629
+ return { ok: false, problem: 'noSuchDay', year, month, days };
630
+ const date = `${String(year).padStart(4, '0')}-${pad2(month)}-${pad2(day)}`;
631
+ if (!options.time)
632
+ return { ok: true, value: date };
633
+ if (!timePart)
634
+ return { ok: true, value: `${date}T00:00` };
635
+ const time = readTime(timePart);
636
+ if (!time.ok)
637
+ return time.problem === 'minuteDigits' ? time : { ok: false, problem: time.problem === 'unfinished' ? 'unfinished' : 'time' };
638
+ return { ok: true, value: `${date}T${time.value}` };
639
+ }
640
+ /**
641
+ * A typed time of day: 24-hour "14:30" (or "14.30", "14h30", "1430"), or
642
+ * twelve-hour with am/pm or ص/م; Arabic digits; seconds only when :00.
643
+ * The value is HH:MM. Minutes of one digit are read as written only when they
644
+ * can be one time alone ("14:7" is 14:07, "14:0" 14:00); "14:3" could be
645
+ * 14:03 or 14:30, and "14.5" 14:05, half past two or 14:50: those are never
646
+ * read, and each time it could be is named ('minuteDigits').
647
+ */
648
+ export function readTime(text) {
649
+ const t = folded(text).trim();
650
+ if (!t)
651
+ return { ok: true, value: null };
652
+ const match = TIME.exec(t) ?? /^(\d{2})(\d{2})()()$/.exec(t);
653
+ if (!match)
654
+ return { ok: false, problem: /^\d{1,2}\s*[:.hH]?$/.test(t) ? 'unfinished' : 'time' };
655
+ const [, h, m, s, half] = match;
656
+ if (m === undefined && /[:.]$/.test(t))
657
+ return { ok: false, problem: 'unfinished' };
658
+ let hours = Number(h);
659
+ const minutes = Number(m ?? 0);
660
+ if (s && Number(s) !== 0)
661
+ return { ok: false, problem: 'time' };
662
+ if (half) {
663
+ if (hours < 1 || hours > 12)
664
+ return { ok: false, problem: 'time' };
665
+ const pm = /^[pP]/.test(half) || half === ARABIC.pm;
666
+ hours = (hours % 12) + (pm ? 12 : 0);
667
+ }
668
+ if (hours > 23 || minutes > 59)
669
+ return { ok: false, problem: 'time' };
670
+ // One digit of minutes that could be more than one time: each is named, none taken.
671
+ const either = m !== undefined && m.length === 1 ? oneDigitMinute(m, /^\d{1,2}\s*([:.hH])/.exec(t)?.[1] ?? ':') : null;
672
+ if (either)
673
+ return { ok: false, problem: 'minuteDigits', times: either.map((minutes) => `${pad2(hours)}:${minutes}`) };
674
+ return { ok: true, value: `${pad2(hours)}:${pad2(minutes)}` };
675
+ }
676
+ // ---------------------------------------------------------------------------
677
+ const currencyPlaces = new Map();
678
+ /** The places a currency's amounts are written with: 3 for the Kuwaiti, Bahraini, Omani and Jordanian dinars and rials, 2 for most. */
679
+ export function placesOfCurrency(currency) {
680
+ if (!currency || !/^[A-Z]{3}$/.test(currency))
681
+ return 2;
682
+ let found = currencyPlaces.get(currency);
683
+ if (found === undefined) {
684
+ try {
685
+ found = new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ?? 2;
686
+ }
687
+ catch {
688
+ found = 2;
689
+ }
690
+ currencyPlaces.set(currency, found);
691
+ }
692
+ return found;
693
+ }
694
+ /** The decimal places a number has as JavaScript writes it: 12.345 has 3, 1e-7 has 7. */
695
+ export function placesOf(value) {
696
+ if (!Number.isFinite(value))
697
+ return 0;
698
+ const text = String(Math.abs(value));
699
+ const exponent = /e([+-]\d+)$/.exec(text);
700
+ const mantissa = exponent ? text.slice(0, exponent.index) : text;
701
+ const dot = mantissa.indexOf('.');
702
+ const places = (dot === -1 ? 0 : mantissa.length - dot - 1) - (exponent ? Number(exponent[1]) : 0);
703
+ return Math.max(0, places);
704
+ }
705
+ //# sourceMappingURL=input-rules.mjs.map