implement support for string form human durations in config
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -37,7 +37,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/types": "workspace:^"
|
||||
"@backstage/types": "workspace:^",
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
|
||||
@@ -15,114 +15,254 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
readDurationFromConfig,
|
||||
propsOfHumanDuration,
|
||||
readDurationFromConfig,
|
||||
} from './readDurationFromConfig';
|
||||
import { ConfigReader } from './reader';
|
||||
|
||||
describe('readDurationFromConfig', () => {
|
||||
it('reads all known keys', () => {
|
||||
const config = new ConfigReader({
|
||||
milliseconds: 1,
|
||||
seconds: 2,
|
||||
minutes: 3,
|
||||
hours: 4,
|
||||
days: 5,
|
||||
weeks: 6,
|
||||
months: 7,
|
||||
years: 8,
|
||||
describe('ISO form', () => {
|
||||
it('parses the known forms', () => {
|
||||
const config = new ConfigReader({
|
||||
d1: 'P2DT6H',
|
||||
d2: 'PT0.5S',
|
||||
d3: 'PT3.1S',
|
||||
d4: 'P1Y2M3W4DT5H6M7.8S',
|
||||
});
|
||||
|
||||
expect(readDurationFromConfig(config, { key: 'd1' })).toEqual({
|
||||
days: 2,
|
||||
hours: 6,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd2' })).toEqual({
|
||||
milliseconds: 500,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd3' })).toEqual({
|
||||
seconds: 3,
|
||||
milliseconds: 100,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd4' })).toEqual({
|
||||
years: 1,
|
||||
months: 2,
|
||||
weeks: 3,
|
||||
days: 4,
|
||||
hours: 5,
|
||||
minutes: 6,
|
||||
seconds: 7,
|
||||
milliseconds: 800,
|
||||
});
|
||||
});
|
||||
expect(readDurationFromConfig(config)).toEqual({
|
||||
milliseconds: 1,
|
||||
seconds: 2,
|
||||
minutes: 3,
|
||||
hours: 4,
|
||||
days: 5,
|
||||
weeks: 6,
|
||||
months: 7,
|
||||
years: 8,
|
||||
|
||||
it('throws on errors', () => {
|
||||
const config = new ConfigReader({
|
||||
d1: 'P 1Y',
|
||||
d2: 'P1L',
|
||||
d3: 'P',
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
readDurationFromConfig(config, { key: 'd1' }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid duration 'P 1Y' in config at 'd1', Error: Invalid ISO format, expected a value similar to 'P2DT6H' (2 days 6 hours) or 'PT1M' (1 minute)"`,
|
||||
);
|
||||
expect(() =>
|
||||
readDurationFromConfig(config, { key: 'd2' }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid duration 'P1L' in config at 'd2', Error: Invalid ISO format, expected a value similar to 'P2DT6H' (2 days 6 hours) or 'PT1M' (1 minute)"`,
|
||||
);
|
||||
expect(() =>
|
||||
readDurationFromConfig(config, { key: 'd3' }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid duration 'P' in config at 'd3', Error: Invalid ISO format, no values given"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all known keys, for a subkey', () => {
|
||||
const config = new ConfigReader({
|
||||
sub: {
|
||||
key: {
|
||||
milliseconds: 1,
|
||||
seconds: 2,
|
||||
minutes: 3,
|
||||
hours: 4,
|
||||
days: 5,
|
||||
weeks: 6,
|
||||
months: 7,
|
||||
years: 8,
|
||||
describe('ms form', () => {
|
||||
it('parses the known units', () => {
|
||||
// this is not exhaustive, but tests all supported units to ensure that
|
||||
// our conversion to HumanDuration form works
|
||||
const config = new ConfigReader({
|
||||
d1: '1y',
|
||||
d2: '2 years',
|
||||
d3: '4w',
|
||||
d4: '5h',
|
||||
d5: '6 hrs',
|
||||
d6: '7min',
|
||||
d7: '9 minutes',
|
||||
d8: '3.5 seconds',
|
||||
d9: '25 ms',
|
||||
d10: '1850ms',
|
||||
});
|
||||
|
||||
expect(readDurationFromConfig(config, { key: 'd1' })).toEqual({
|
||||
years: 1,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd2' })).toEqual({
|
||||
years: 2,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd3' })).toEqual({
|
||||
weeks: 4,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd4' })).toEqual({
|
||||
hours: 5,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd5' })).toEqual({
|
||||
hours: 6,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd6' })).toEqual({
|
||||
minutes: 7,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd7' })).toEqual({
|
||||
minutes: 9,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd8' })).toEqual({
|
||||
seconds: 3,
|
||||
milliseconds: 500,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd9' })).toEqual({
|
||||
milliseconds: 25,
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'd10' })).toEqual({
|
||||
seconds: 1,
|
||||
milliseconds: 850,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on errors', () => {
|
||||
const config = new ConfigReader({
|
||||
d1: '1m 3s',
|
||||
d2: '-3s',
|
||||
d3: '',
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
readDurationFromConfig(config, { key: 'd1' }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid duration '1m 3s' in config at 'd1', Error: Not a valid duration string, try a number followed by a unit such as '1d' or '2 seconds'"`,
|
||||
);
|
||||
expect(() =>
|
||||
readDurationFromConfig(config, { key: 'd2' }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid duration '-3s' in config at 'd2', Error: Negative durations are not allowed"`,
|
||||
);
|
||||
expect(() =>
|
||||
readDurationFromConfig(config, { key: 'd3' }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'd3' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('object form', () => {
|
||||
it('reads all known keys', () => {
|
||||
const config = new ConfigReader({
|
||||
milliseconds: 1,
|
||||
seconds: 2,
|
||||
minutes: 3,
|
||||
hours: 4,
|
||||
days: 5,
|
||||
weeks: 6,
|
||||
months: 7,
|
||||
years: 8,
|
||||
});
|
||||
expect(readDurationFromConfig(config)).toEqual({
|
||||
milliseconds: 1,
|
||||
seconds: 2,
|
||||
minutes: 3,
|
||||
hours: 4,
|
||||
days: 5,
|
||||
weeks: 6,
|
||||
months: 7,
|
||||
years: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all known keys, for a subkey', () => {
|
||||
const config = new ConfigReader({
|
||||
sub: {
|
||||
key: {
|
||||
milliseconds: 1,
|
||||
seconds: 2,
|
||||
minutes: 3,
|
||||
hours: 4,
|
||||
days: 5,
|
||||
weeks: 6,
|
||||
months: 7,
|
||||
years: 8,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'sub.key' })).toEqual({
|
||||
milliseconds: 1,
|
||||
seconds: 2,
|
||||
minutes: 3,
|
||||
hours: 4,
|
||||
days: 5,
|
||||
weeks: 6,
|
||||
months: 7,
|
||||
years: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects wrong type of target, for a subkey', () => {
|
||||
const config = new ConfigReader({
|
||||
sub: { key: 7 },
|
||||
});
|
||||
expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow(
|
||||
"Failed to read duration from config, TypeError: Invalid type in config for key 'sub.key' in 'mock-config', got number, wanted object",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects no keys', () => {
|
||||
const config = new ConfigReader({});
|
||||
expect(() => readDurationFromConfig(config)).toThrow(
|
||||
`Failed to read duration from config, Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects no keys, for a subkey', () => {
|
||||
const config = new ConfigReader({ sub: { key: {} } });
|
||||
expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow(
|
||||
`Failed to read duration from config at 'sub.key', Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown keys', () => {
|
||||
const config = new ConfigReader({
|
||||
minutes: 3,
|
||||
invalid: 'value',
|
||||
});
|
||||
expect(() => readDurationFromConfig(config)).toThrow(
|
||||
`Failed to read duration from config, Error: Unknown property 'invalid'; expected one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(propsOfHumanDuration)('rejects non-number %p', prop => {
|
||||
const config = new ConfigReader({
|
||||
[prop]: 'value',
|
||||
});
|
||||
expect(() => readDurationFromConfig(config)).toThrow(
|
||||
`Failed to read duration from config, Error: Unable to convert config value for key '${prop}' in 'mock-config' to a number`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(propsOfHumanDuration)(
|
||||
'rejects non-number %p, for a subkey',
|
||||
prop => {
|
||||
const config = new ConfigReader({
|
||||
sub: {
|
||||
key: {
|
||||
[prop]: 'value',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() =>
|
||||
readDurationFromConfig(config, { key: 'sub.key' }),
|
||||
).toThrow(
|
||||
`Failed to read duration from config, Error: Unable to convert config value for key 'sub.key.${prop}' in 'mock-config' to a number`,
|
||||
);
|
||||
},
|
||||
});
|
||||
expect(readDurationFromConfig(config, { key: 'sub.key' })).toEqual({
|
||||
milliseconds: 1,
|
||||
seconds: 2,
|
||||
minutes: 3,
|
||||
hours: 4,
|
||||
days: 5,
|
||||
weeks: 6,
|
||||
months: 7,
|
||||
years: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects wrong type of target, for a subkey', () => {
|
||||
const config = new ConfigReader({
|
||||
sub: { key: 7 },
|
||||
});
|
||||
expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow(
|
||||
"Failed to read duration from config, TypeError: Invalid type in config for key 'sub.key' in 'mock-config', got number, wanted object",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects no keys', () => {
|
||||
const config = new ConfigReader({});
|
||||
expect(() => readDurationFromConfig(config)).toThrow(
|
||||
`Failed to read duration from config, Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects no keys, for a subkey', () => {
|
||||
const config = new ConfigReader({ sub: { key: {} } });
|
||||
expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow(
|
||||
`Failed to read duration from config at 'sub.key', Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown keys', () => {
|
||||
const config = new ConfigReader({
|
||||
minutes: 3,
|
||||
invalid: 'value',
|
||||
});
|
||||
expect(() => readDurationFromConfig(config)).toThrow(
|
||||
`Failed to read duration from config, Error: Unknown property 'invalid'; expected one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(propsOfHumanDuration)('rejects non-number %p', prop => {
|
||||
const config = new ConfigReader({
|
||||
[prop]: 'value',
|
||||
});
|
||||
expect(() => readDurationFromConfig(config)).toThrow(
|
||||
`Failed to read duration from config, Error: Unable to convert config value for key '${prop}' in 'mock-config' to a number`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(propsOfHumanDuration)('rejects non-number %p, for a subkey', prop => {
|
||||
const config = new ConfigReader({
|
||||
sub: {
|
||||
key: {
|
||||
[prop]: 'value',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow(
|
||||
`Failed to read duration from config, Error: Unable to convert config value for key 'sub.key.${prop}' in 'mock-config' to a number`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { InputError, stringifyError } from '@backstage/errors';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
import ms from 'ms';
|
||||
|
||||
export const propsOfHumanDuration = [
|
||||
'years',
|
||||
@@ -30,18 +31,29 @@ export const propsOfHumanDuration = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Reads a duration from a config object.
|
||||
* Reads a duration from config.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* The supported formats are:
|
||||
*
|
||||
* - A string in the format of '1d', '2 seconds' etc. as supported by the `ms`
|
||||
* library.
|
||||
* - A standard ISO formatted duration string, e.g. 'P2DT6H' or 'PT1M'.
|
||||
* - An object with individual units (in plural) as keys, e.g. `{ days: 2, hours: 6 }`.
|
||||
*
|
||||
* The string forms are naturally only supported if the `options.key` argument
|
||||
* is passed, since a `Config` argument always represents an object by its
|
||||
* nature.
|
||||
*
|
||||
* This does not support optionality; if you want to support optional durations,
|
||||
* you need to first check the presence of the target with `config.has(...)` and
|
||||
* then call this function.
|
||||
*
|
||||
* @param config - A configuration object
|
||||
* @param key - If specified, read the duration from the given subkey
|
||||
* under the config object
|
||||
* @param key - If specified, read the duration from the given subkey under the
|
||||
* config object
|
||||
* @returns A duration object
|
||||
*/
|
||||
export function readDurationFromConfig(
|
||||
@@ -49,6 +61,33 @@ export function readDurationFromConfig(
|
||||
options?: {
|
||||
key?: string;
|
||||
},
|
||||
): HumanDuration {
|
||||
if (options?.key && typeof config.getOptional(options.key) === 'string') {
|
||||
const value = config.getString(options.key).trim();
|
||||
try {
|
||||
return value.startsWith('P')
|
||||
? parseIsoDuration(value)
|
||||
: parseMsDuration(value);
|
||||
} catch (error) {
|
||||
throw new InputError(
|
||||
`Invalid duration '${value}' in config at '${
|
||||
options.key
|
||||
}', ${stringifyError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return parseObjectDuration(config, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the object form of durations.
|
||||
*/
|
||||
export function parseObjectDuration(
|
||||
config: Config,
|
||||
options?: {
|
||||
key?: string;
|
||||
},
|
||||
): HumanDuration {
|
||||
let root: Config;
|
||||
let found = false;
|
||||
@@ -94,8 +133,178 @@ export function readDurationFromConfig(
|
||||
if (options?.key) {
|
||||
prefix += ` at '${options.key}'`;
|
||||
}
|
||||
throw new InputError(`${prefix}, ${error}`);
|
||||
throw new Error(`${prefix}, ${error}`);
|
||||
}
|
||||
|
||||
return result as HumanDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses friendly string durations like '1d', '2 seconds' etc using the ms
|
||||
* library.
|
||||
*/
|
||||
export function parseMsDuration(input: string): HumanDuration {
|
||||
if (/^\d+$/.exec(input)) {
|
||||
// We explicitly disallow the only-digits form of the ms library, because
|
||||
// from a configuration perspective it's just confusing to even be able to
|
||||
// specify that
|
||||
throw new Error(
|
||||
`The value cannot be a plain number; try adding a unit like 'ms' or 'seconds'`,
|
||||
);
|
||||
}
|
||||
|
||||
let milliseconds = ms(input);
|
||||
if (!Number.isFinite(milliseconds)) {
|
||||
throw new Error(
|
||||
`Not a valid duration string, try a number followed by a unit such as '1d' or '2 seconds'`,
|
||||
);
|
||||
} else if (milliseconds < 0) {
|
||||
throw new Error('Negative durations are not allowed');
|
||||
} else if (milliseconds === 0) {
|
||||
return { milliseconds: 0 };
|
||||
}
|
||||
|
||||
// As used by the ms library
|
||||
const s = 1000;
|
||||
const m = s * 60;
|
||||
const h = m * 60;
|
||||
const d = h * 24;
|
||||
const w = d * 7;
|
||||
const y = d * 365.25;
|
||||
|
||||
const result: HumanDuration = {};
|
||||
|
||||
if (milliseconds >= y) {
|
||||
const years = Math.floor(milliseconds / y);
|
||||
milliseconds -= years * y;
|
||||
result.years = years;
|
||||
}
|
||||
|
||||
if (milliseconds >= w) {
|
||||
const weeks = Math.floor(milliseconds / w);
|
||||
milliseconds -= weeks * w;
|
||||
result.weeks = weeks;
|
||||
}
|
||||
|
||||
if (milliseconds >= d) {
|
||||
const days = Math.floor(milliseconds / d);
|
||||
milliseconds -= days * d;
|
||||
result.days = days;
|
||||
}
|
||||
|
||||
if (milliseconds >= h) {
|
||||
const hours = Math.floor(milliseconds / h);
|
||||
milliseconds -= hours * h;
|
||||
result.hours = hours;
|
||||
}
|
||||
|
||||
if (milliseconds >= m) {
|
||||
const minutes = Math.floor(milliseconds / m);
|
||||
milliseconds -= minutes * m;
|
||||
result.minutes = minutes;
|
||||
}
|
||||
|
||||
if (milliseconds >= s) {
|
||||
const seconds = Math.floor(milliseconds / s);
|
||||
milliseconds -= seconds * s;
|
||||
result.seconds = seconds;
|
||||
}
|
||||
|
||||
if (milliseconds > 0) {
|
||||
result.milliseconds = milliseconds;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO formatted duration string.
|
||||
*
|
||||
* Implementation taken from luxon's Duration.fromISO to not force that
|
||||
* dependency on everyone.
|
||||
*/
|
||||
export function parseIsoDuration(input: string): HumanDuration {
|
||||
const match =
|
||||
/^-?P(?:(?:(-?\d{1,20}(?:\.\d{1,20})?)Y)?(?:(-?\d{1,20}(?:\.\d{1,20})?)M)?(?:(-?\d{1,20}(?:\.\d{1,20})?)W)?(?:(-?\d{1,20}(?:\.\d{1,20})?)D)?(?:T(?:(-?\d{1,20}(?:\.\d{1,20})?)H)?(?:(-?\d{1,20}(?:\.\d{1,20})?)M)?(?:(-?\d{1,20})(?:[.,](-?\d{1,20}))?S)?)?)$/.exec(
|
||||
input,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid ISO format, expected a value similar to 'P2DT6H' (2 days 6 hours) or 'PT1M' (1 minute)`,
|
||||
);
|
||||
}
|
||||
|
||||
const [
|
||||
s,
|
||||
yearStr,
|
||||
monthStr,
|
||||
weekStr,
|
||||
dayStr,
|
||||
hourStr,
|
||||
minuteStr,
|
||||
secondStr,
|
||||
millisecondsStr,
|
||||
] = match;
|
||||
|
||||
const hasNegativePrefix = s[0] === '-';
|
||||
const negativeSeconds = !!secondStr && secondStr[0] === '-';
|
||||
|
||||
const maybeNegate = (num: number | undefined, force = false) =>
|
||||
num !== undefined && (force || (num && hasNegativePrefix)) ? -num : num;
|
||||
|
||||
const parseFloating = (value: string) => {
|
||||
if (typeof value === 'undefined' || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
return parseFloat(value);
|
||||
};
|
||||
|
||||
const parseMillis = (fraction: string | undefined) => {
|
||||
// Return undefined (instead of 0) in these cases, where fraction is not set
|
||||
if (
|
||||
typeof fraction === 'undefined' ||
|
||||
fraction === null ||
|
||||
fraction === ''
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const f = parseFloat(`0.${fraction}`) * 1000;
|
||||
return Math.floor(f);
|
||||
};
|
||||
|
||||
const years = maybeNegate(parseFloating(yearStr));
|
||||
const months = maybeNegate(parseFloating(monthStr));
|
||||
const weeks = maybeNegate(parseFloating(weekStr));
|
||||
const days = maybeNegate(parseFloating(dayStr));
|
||||
const hours = maybeNegate(parseFloating(hourStr));
|
||||
const minutes = maybeNegate(parseFloating(minuteStr));
|
||||
const seconds = maybeNegate(parseFloating(secondStr), secondStr === '-0');
|
||||
const milliseconds = maybeNegate(
|
||||
parseMillis(millisecondsStr),
|
||||
negativeSeconds,
|
||||
);
|
||||
|
||||
if (
|
||||
years === undefined &&
|
||||
months === undefined &&
|
||||
weeks === undefined &&
|
||||
days === undefined &&
|
||||
hours === undefined &&
|
||||
minutes === undefined &&
|
||||
seconds === undefined &&
|
||||
milliseconds === undefined
|
||||
) {
|
||||
throw new Error('Invalid ISO format, no values given');
|
||||
}
|
||||
|
||||
return {
|
||||
...(years ? { years } : {}),
|
||||
...(months ? { months } : {}),
|
||||
...(weeks ? { weeks } : {}),
|
||||
...(days ? { days } : {}),
|
||||
...(hours ? { hours } : {}),
|
||||
...(minutes ? { minutes } : {}),
|
||||
...(seconds ? { seconds } : {}),
|
||||
...(milliseconds ? { milliseconds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user