@knime/utils 1.1.3 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @knime/utils
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 893d560: Add IntervalInput component for setting either date or time intervals
8
+
3
9
  ## 1.1.3
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knime/utils",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "Internal utility functions and constants shared across @knime packages.",
5
5
  "homepage": "https://knime.github.io/webapps-common/",
6
6
  "license": "GPL 3 and Additional Permissions according to Sec. 7 (SEE the file LICENSE)",
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  KnownHotkey as _KnownHotkey,
20
20
  } from "./formatHotkeys";
21
21
  import getWrappedAroundIndex from "./getWrappedAroundIndex";
22
+ import * as intervalUtils from "./intervalUtils";
22
23
  import getLocalTimeZone from "./localTimezone";
23
24
  import * as navigatorUtils from "./navigator";
24
25
  import numIntegerDigits from "./numIntegerDigits";
@@ -65,6 +66,7 @@ export {
65
66
  isAfterMaxDate,
66
67
  isBeforeMinDate,
67
68
  isIconExisting,
69
+ intervalUtils,
68
70
  navigatorUtils,
69
71
  numIntegerDigits,
70
72
  sleep,
@@ -0,0 +1,437 @@
1
+ export type Period = {
2
+ negative: boolean;
3
+ years: number;
4
+ months: number;
5
+ weeks: number;
6
+ days: number;
7
+ };
8
+
9
+ export type Duration = {
10
+ negative: boolean;
11
+ hours: number;
12
+ minutes: number;
13
+ seconds: number;
14
+ milliseconds: number;
15
+ };
16
+
17
+ type AllowedIntervalTypes = "DATE" | "TIME";
18
+ type AllowedIntervalTypesOrEither = AllowedIntervalTypes | "DATE_OR_TIME";
19
+
20
+ type IntervalStringValidationResult =
21
+ | {
22
+ valid: true;
23
+ type: AllowedIntervalTypes;
24
+ negative: boolean;
25
+ zero: boolean;
26
+ }
27
+ | { valid: false };
28
+
29
+ export type PeriodOrDuration =
30
+ | {
31
+ interval: Period;
32
+ type: "DATE";
33
+ }
34
+ | {
35
+ interval: Duration;
36
+ type: "TIME";
37
+ };
38
+
39
+ const extractPeriodFromRegexMatch = (
40
+ negative: boolean,
41
+ match: RegExpExecArray,
42
+ ): Period => {
43
+ return {
44
+ negative,
45
+ years: parseInt(match[1] ?? "0", 10),
46
+ months: parseInt(match[2] ?? "0", 10),
47
+ weeks: parseInt(match[3] ?? "0", 10),
48
+ days: parseInt(match[4] ?? "0", 10),
49
+ };
50
+ };
51
+
52
+ const extractDurationFromRegexMatch = (
53
+ negative: boolean,
54
+ match: RegExpExecArray,
55
+ ): Duration => {
56
+ // eslint-disable-next-line no-magic-numbers
57
+ const milliseconds = parseInt(match[4]?.padEnd(3, "0") ?? "0", 10);
58
+
59
+ return {
60
+ negative,
61
+ hours: parseInt(match[1] ?? "0", 10),
62
+ minutes: parseInt(match[2] ?? "0", 10),
63
+ seconds: parseInt(match[3] ?? "0", 10),
64
+ milliseconds,
65
+ };
66
+ };
67
+
68
+ export const isZero = (interval: PeriodOrDuration): boolean => {
69
+ if (interval.type === "DATE") {
70
+ return (
71
+ interval.interval.years === 0 &&
72
+ interval.interval.months === 0 &&
73
+ interval.interval.weeks === 0 &&
74
+ interval.interval.days === 0
75
+ );
76
+ } else {
77
+ return (
78
+ interval.interval.hours === 0 &&
79
+ interval.interval.minutes === 0 &&
80
+ interval.interval.seconds === 0 &&
81
+ interval.interval.milliseconds === 0
82
+ );
83
+ }
84
+ };
85
+
86
+ /**
87
+ * Take an interval object and convert it to an ISO duration or period string. All parts of the
88
+ * relevant part of the Interval object are included in the output, even if they are zero.
89
+ *
90
+ * Technically the ISO standard allows to mix period and duration parts, but we don't support that
91
+ * here because it's incredibly hard to deal with all the edge cases. Maybe in a future ticket.
92
+ *
93
+ * The output here is allowed to be negative, in which case the minus sign is prepended to the
94
+ * output.
95
+ *
96
+ * @param toFormat The PeriodOrDuration object to format
97
+ */
98
+ export const formatIntervalToISOIntervalString = (
99
+ toFormat: PeriodOrDuration,
100
+ ): string => {
101
+ let output = "P";
102
+
103
+ if (toFormat.type === "DATE") {
104
+ output += `${toFormat.interval.years}Y${toFormat.interval.months}M${toFormat.interval.weeks}W${toFormat.interval.days}D`;
105
+ } else {
106
+ output += `T${toFormat.interval.hours}H${toFormat.interval.minutes}M${toFormat.interval.seconds}.${toFormat.interval.milliseconds
107
+ .toString()
108
+ // eslint-disable-next-line no-magic-numbers
109
+ .padStart(3, "0")}S`;
110
+ }
111
+
112
+ if (toFormat.interval.negative) {
113
+ output = `-${output}`;
114
+ }
115
+ return output;
116
+ };
117
+
118
+ /**
119
+ * Take an interval object and convert it to a human readable string. Only non-zero parts are
120
+ * included in the output (unless all parts are 0).
121
+ *
122
+ * The output here is allowed to be negative, in which case the minus sign is prepended to the
123
+ * output and we wrap the whole thing in brackets e.g. -(1 year 2 days).
124
+ *
125
+ * @param toFormat The PeriodOrDuration object to format
126
+ */
127
+ export const formatIntervalToHumanReadableIntervalString = (
128
+ toFormat: PeriodOrDuration,
129
+ ): string => {
130
+ const formatPart = (value: number, unit: string): string => {
131
+ if (value === 0) {
132
+ return "";
133
+ } else if (value === 1) {
134
+ return `${value} ${unit} `;
135
+ } else {
136
+ return `${value} ${unit}s `;
137
+ }
138
+ };
139
+
140
+ let output = "";
141
+
142
+ if (toFormat.type === "DATE") {
143
+ if (isZero(toFormat)) {
144
+ return "0 days";
145
+ }
146
+
147
+ output += formatPart(toFormat.interval.years, "year");
148
+ output += formatPart(toFormat.interval.months, "month");
149
+ output += formatPart(toFormat.interval.weeks, "week");
150
+ output += formatPart(toFormat.interval.days, "day");
151
+ } else {
152
+ if (isZero(toFormat)) {
153
+ return "0 seconds";
154
+ }
155
+
156
+ output += formatPart(toFormat.interval.hours, "hour");
157
+ output += formatPart(toFormat.interval.minutes, "minute");
158
+
159
+ const milliSecondsZero = toFormat.interval.milliseconds === 0;
160
+ if (toFormat.interval.seconds !== 0 || !milliSecondsZero) {
161
+ if (milliSecondsZero) {
162
+ output += formatPart(toFormat.interval.seconds, "second");
163
+ } else {
164
+ output += `${toFormat.interval.seconds}.${toFormat.interval.milliseconds.toString().padStart(3, "0")} seconds `;
165
+ }
166
+ }
167
+ }
168
+
169
+ if (toFormat.interval.negative) {
170
+ output = `-(${output.trim()})`;
171
+ }
172
+
173
+ return output.trim();
174
+ };
175
+
176
+ const yearsPart = "(\\d+)\\s*y(?:ears?)?";
177
+ const monthsPart = "(\\d+)\\s*m(?:o(?:nths?)?)?";
178
+ const weeksPart = "(\\d+)\\s*w(?:eeks?)?";
179
+ const daysPart = "(\\d+)\\s*d(?:ays?)?";
180
+ const hoursPart = "(\\d+)\\s*h(?:ours?)?";
181
+ const minutesPart = "(\\d+)\\s*m(?:in(?:s|utes?)?)?";
182
+ const secondsPart = "(\\d+)(?:[,.](\\d{1,3}))?\\s*s(?:ec(?:s|onds?)?)?";
183
+
184
+ /**
185
+ * Matches a human readable period string, e.g. "1y 2mo 3weeks 4day" (each part is optional)
186
+ */
187
+ const humanReadablePeriodRegex = new RegExp(
188
+ `^(?:${yearsPart})?\\s*(?:${monthsPart})?\\s*(?:${weeksPart})?\\s*(?:${daysPart})?\\s*$`,
189
+ "i",
190
+ );
191
+
192
+ /**
193
+ * Matches a human readable duration string, e.g. "1h 2m 3.456s" (each part is optional)
194
+ */
195
+ const humanReadableDurationRegex = new RegExp(
196
+ `^(?:${hoursPart})?\\s*(?:${minutesPart})?\\s*(?:${secondsPart})?$`,
197
+ "i",
198
+ );
199
+
200
+ /**
201
+ * Matches a negative human readable period string, e.g. "-(1y 2mo 3weeks 4day)" (each part is optional)
202
+ */
203
+ const negativeHumanReadablePeriodRegex = new RegExp(
204
+ `^\\s*-\\s*\\(\\s*${humanReadablePeriodRegex.source.replace(/[$^]/g, "")}\\s*\\)\\s*$`,
205
+ "i",
206
+ );
207
+
208
+ /**
209
+ * Matches a negative human readable period string with one field like "-1y" or "-1w"
210
+ */
211
+ const negativeHumanReadablePeriodRegexWithSingleField = new RegExp(
212
+ `^\\s*-\\s*(?:\\s*${yearsPart}|${monthsPart}|${weeksPart}|${daysPart})\\s*$`,
213
+ "i",
214
+ );
215
+
216
+ /**
217
+ * Matches a negative human readable duration string, e.g. "-(1h 2m 3.456s)" (each part is optional)
218
+ */
219
+ const negativeHumanReadableDurationRegex = new RegExp(
220
+ `^\\s*-\\s*\\(\\s*${humanReadableDurationRegex.source.replace(/[$^]/g, "")}\\s*\\)\\s*$`,
221
+ "i",
222
+ );
223
+
224
+ /**
225
+ * Matches a negative human readable duration string with one field like "-1h" or "-1.5s"
226
+ */
227
+ const negativeHumanReadableDurationRegexWithSingleField = new RegExp(
228
+ `^\\s*-\\s*(?:\\s*${hoursPart}|${minutesPart}|${secondsPart})\\s*$`,
229
+ "i",
230
+ );
231
+
232
+ /**
233
+ * Try to parse a human readable interval string into an Interval object. Any parts not specified
234
+ * are assumed to be zero. Note that the string must be either a period or a duration, not a mix of
235
+ * both. So for example, "1y 2mo 3weeks 4day" is fine, as is "1h 2m 3.456s". However, "1y 2mo 1sec"
236
+ * is not, since it's a mix of both.
237
+ *
238
+ * Negative intervals are allowed, although if multiple fields are provided the string must include
239
+ * brackets, so "-(1y 2mo)" is also valid, as is "-5 seconds".
240
+ *
241
+ * The string is required to have at least some content, any string that is blank will throw an error.
242
+ *
243
+ * @param intervalString The human readable time string
244
+ */
245
+ export const parseHumanReadableIntervalString = (
246
+ intervalString: string,
247
+ ): PeriodOrDuration => {
248
+ intervalString = intervalString?.trim();
249
+
250
+ if (!intervalString) {
251
+ throw new Error("Empty interval string");
252
+ }
253
+
254
+ const matchPositivePeriod = humanReadablePeriodRegex.exec(intervalString);
255
+ const matchNegativePeriod =
256
+ negativeHumanReadablePeriodRegex.exec(intervalString) ??
257
+ negativeHumanReadablePeriodRegexWithSingleField.exec(intervalString);
258
+ const matchPositiveDuration = humanReadableDurationRegex.exec(intervalString);
259
+ const matchNegativeDuration =
260
+ negativeHumanReadableDurationRegex.exec(intervalString) ??
261
+ negativeHumanReadableDurationRegexWithSingleField.exec(intervalString);
262
+
263
+ const matchPeriod = matchPositivePeriod ?? matchNegativePeriod;
264
+ const matchDuration = matchPositiveDuration ?? matchNegativeDuration;
265
+ const negative = (matchNegativePeriod ?? matchNegativeDuration) !== null;
266
+
267
+ // I think this will happen only for the string "1M" or similar
268
+ if (matchDuration && matchPeriod) {
269
+ throw new Error("Ambiguous time format");
270
+ }
271
+
272
+ if (matchPeriod) {
273
+ return {
274
+ type: "DATE",
275
+ interval: extractPeriodFromRegexMatch(negative, matchPeriod),
276
+ };
277
+ } else if (matchDuration) {
278
+ return {
279
+ type: "TIME",
280
+ interval: extractDurationFromRegexMatch(negative, matchDuration),
281
+ };
282
+ } else {
283
+ throw new Error(`Invalid human readable time: ${intervalString}`);
284
+ }
285
+ };
286
+
287
+ /**
288
+ * Check if the given string can be understood as a human readable duration or period string. The
289
+ * type parameter specifies whether the string should be a date, time, or either.
290
+ *
291
+ * @param time the string to check
292
+ * @param expectedType the type of the string - either "DATE", "TIME", or "ANY"
293
+ */
294
+ export const isValidHumanReadableIntervalString = (
295
+ time: string,
296
+ expectedType: AllowedIntervalTypesOrEither,
297
+ ): IntervalStringValidationResult => {
298
+ try {
299
+ const validationResult = parseHumanReadableIntervalString(time);
300
+ const actualType = validationResult.type;
301
+ const negative = validationResult.interval.negative;
302
+
303
+ if (actualType === expectedType || expectedType === "DATE_OR_TIME") {
304
+ return {
305
+ valid: true,
306
+ type: actualType,
307
+ negative,
308
+ zero: isZero(validationResult),
309
+ };
310
+ } else {
311
+ return { valid: false };
312
+ }
313
+ } catch (e) {
314
+ return { valid: false };
315
+ }
316
+ };
317
+
318
+ /**
319
+ * Convert an ISO duration OR period string to an Interval object. So for example,
320
+ * "P1Y2M3W4D" is fine, as is "PT1H2M3.456S". However, "P1Y2M3W4D1H2M3.456S" is not, since
321
+ * it's a mix of both. We're more forgiving than the ISO standard, so we allow for missing parts.
322
+ * Also, the string is allowed to be negative, so "-P1Y2M3W4D" is also valid.
323
+ *
324
+ * The string is required to have at least some content, so the strings "", "P", and "PT" are
325
+ * considered invalid (also if they start with a -).
326
+ *
327
+ * @param isoString The ISO duration or period string.
328
+ */
329
+ export const parseISOIntervalString = (isoString: string): PeriodOrDuration => {
330
+ isoString = isoString?.trim();
331
+
332
+ if (["", "P", "PT", "-", "-P", "-PT"].includes(isoString)) {
333
+ throw new Error("Empty ISO duration");
334
+ }
335
+
336
+ const periodRegex = /^-?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?$/;
337
+ const durationRegex =
338
+ /^-?PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:[,.](\d{1,3}))?S)?$/;
339
+
340
+ const matchPeriod = periodRegex.exec(isoString);
341
+ const matchDuration = durationRegex.exec(isoString);
342
+
343
+ if (matchPeriod) {
344
+ return {
345
+ type: "DATE",
346
+ interval: extractPeriodFromRegexMatch(
347
+ isoString.startsWith("-"),
348
+ matchPeriod,
349
+ ),
350
+ };
351
+ } else if (matchDuration) {
352
+ return {
353
+ type: "TIME",
354
+ interval: extractDurationFromRegexMatch(
355
+ isoString.startsWith("-"),
356
+ matchDuration,
357
+ ),
358
+ };
359
+ } else {
360
+ throw new Error(`Invalid ISO duration: ${isoString}`);
361
+ }
362
+ };
363
+
364
+ /**
365
+ * Check if the given string is a valid duration or period ISO string. The type parameter
366
+ * specifies whether the string should be a date, time, or either.
367
+ *
368
+ * @param isoString the string to check
369
+ * @param type the type of the string - either "DATE", "TIME", or "ANY"
370
+ */
371
+ export const isValidISOIntervalString = (
372
+ isoString: string,
373
+ type: AllowedIntervalTypesOrEither,
374
+ ): IntervalStringValidationResult => {
375
+ try {
376
+ const validationResult = parseISOIntervalString(isoString);
377
+ const actualType = validationResult.type;
378
+ const negative = validationResult.interval.negative;
379
+
380
+ if (actualType === type || type === "DATE_OR_TIME") {
381
+ return {
382
+ valid: true,
383
+ type: actualType,
384
+ negative,
385
+ zero: isZero(validationResult),
386
+ };
387
+ } else {
388
+ return { valid: false };
389
+ }
390
+ } catch (e) {
391
+ return { valid: false };
392
+ }
393
+ };
394
+
395
+ /**
396
+ * Try to convert an interval string to an Interval object. The string can be either an ISO
397
+ * duration/period string or a human readable string. If the string is not valid, an error is
398
+ * thrown.
399
+ *
400
+ * @param time the time string, either ISO or human readable
401
+ */
402
+ export const parseGenericIntervalString = (time: string): PeriodOrDuration => {
403
+ const validISO = isValidISOIntervalString(time, "DATE_OR_TIME");
404
+ const validHumanReadable = isValidHumanReadableIntervalString(
405
+ time,
406
+ "DATE_OR_TIME",
407
+ );
408
+ if (validISO.valid) {
409
+ return parseISOIntervalString(time);
410
+ } else if (validHumanReadable.valid) {
411
+ return parseHumanReadableIntervalString(time);
412
+ } else {
413
+ throw new Error(`Invalid interval string: ${time}`);
414
+ }
415
+ };
416
+
417
+ /**
418
+ * Check if the given string is a valid interval string, either human readable or ISO. The type parameter
419
+ * specifies whether the string should be a period, duration, or either.
420
+ *
421
+ * @param time the time string to check
422
+ * @param type the type of the string - either "DATE", "TIME", or "ANY"
423
+ */
424
+ export const isValidIntervalString = (
425
+ time: string,
426
+ type: AllowedIntervalTypesOrEither,
427
+ ): IntervalStringValidationResult => {
428
+ const validISO = isValidISOIntervalString(time, type);
429
+ const validHumanReadable = isValidHumanReadableIntervalString(time, type);
430
+ if (validISO.valid) {
431
+ return validISO;
432
+ } else if (validHumanReadable.valid) {
433
+ return validHumanReadable;
434
+ } else {
435
+ return { valid: false };
436
+ }
437
+ };