@nixxie-cms/fields-duration 1.0.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/LICENSE +23 -0
- package/README.md +22 -0
- package/dist/declarations/src/index.d.ts +35 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/nixxie-cms-fields-duration.cjs.d.ts +2 -0
- package/dist/nixxie-cms-fields-duration.cjs.js +133 -0
- package/dist/nixxie-cms-fields-duration.esm.js +127 -0
- package/package.json +33 -0
- package/src/index.ts +141 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixxie International DMCC
|
|
4
|
+
Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
|
|
5
|
+
(this software is derived from the KeystoneJS project, https://keystonejs.com)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# @nixxie-cms/fields-duration
|
|
2
|
+
|
|
3
|
+
A duration field for Nixxie CMS. Stores a whole number of **seconds** on the core `integer`
|
|
4
|
+
field, with optional `min`/`max` bounds enforced via validation hooks.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { duration } from '@nixxie-cms/fields-duration'
|
|
8
|
+
|
|
9
|
+
fields: {
|
|
10
|
+
runtime: duration({
|
|
11
|
+
min: 60, // at least one minute
|
|
12
|
+
max: 4 * 3600, // at most four hours
|
|
13
|
+
validation: { isRequired: true },
|
|
14
|
+
}),
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Also exports dependency-free helpers:
|
|
19
|
+
|
|
20
|
+
- `formatDuration(seconds)` → `"1h 30m 5s"` (omits zero units, `"0s"` for 0)
|
|
21
|
+
- `parseDuration(input)` → seconds or `null`; accepts `"90"`, `"90s"`, `"1h 30m"`,
|
|
22
|
+
`"1:30:05"` and ISO-8601 (`"PT1H30M5S"`)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { IntegerFieldConfig } from '@nixxie-cms/core/fields';
|
|
2
|
+
import type { BaseCollectionTypeInfo, FieldTypeFunc } from '@nixxie-cms/core/types';
|
|
3
|
+
/**
|
|
4
|
+
* Format a duration in seconds as `"1h 30m 5s"`. Zero units are omitted (`3600` → `"1h"`),
|
|
5
|
+
* zero itself is `"0s"`, and negative durations get a leading `-`. Hours are not rolled up
|
|
6
|
+
* into days (`90000` → `"25h"`). Fractional input is rounded; non-finite input yields `''`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function formatDuration(seconds: number): string;
|
|
9
|
+
/**
|
|
10
|
+
* Parse a human-entered duration into whole seconds. Dependency-free; accepts:
|
|
11
|
+
* - plain seconds: `"90"`
|
|
12
|
+
* - unit tokens: `"90s"`, `"1h 30m"`, `"2d 4h"` (units `d`, `h`, `m`, `s`, case-insensitive)
|
|
13
|
+
* - colon notation: `"1:30:05"` (h:mm:ss) or `"1:30"` (m:ss)
|
|
14
|
+
* - ISO-8601 durations: `"PT1H30M5S"`, `"P1DT12H"` (day/time components only)
|
|
15
|
+
*
|
|
16
|
+
* Fractional values are rounded to the nearest second. Returns `null` when unparseable.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseDuration(input: string): number | null;
|
|
19
|
+
export type DurationFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<IntegerFieldConfig<CollectionTypeInfo>, 'validation'> & {
|
|
20
|
+
/** Smallest allowed duration, in seconds. */
|
|
21
|
+
min?: number;
|
|
22
|
+
/** Largest allowed duration, in seconds. */
|
|
23
|
+
max?: number;
|
|
24
|
+
validation?: {
|
|
25
|
+
/** Reject saving when no value is present. */
|
|
26
|
+
isRequired?: boolean;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* A duration field storing a whole number of SECONDS. Built on the core `integer` field with
|
|
31
|
+
* optional `min`/`max` bounds enforced via a validate hook. Use `formatDuration` /
|
|
32
|
+
* `parseDuration` to convert to and from human-readable forms.
|
|
33
|
+
*/
|
|
34
|
+
export declare function duration<CollectionTypeInfo extends BaseCollectionTypeInfo>(config?: DurationFieldConfig<CollectionTypeInfo>): FieldTypeFunc<CollectionTypeInfo>;
|
|
35
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAMnF;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAatD;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiD1D;AAED,MAAM,MAAM,mBAAmB,CAAC,kBAAkB,SAAS,sBAAsB,IAAI,IAAI,CACvF,kBAAkB,CAAC,kBAAkB,CAAC,EACtC,YAAY,CACb,GAAG;IACF,6CAA6C;IAC7C,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,UAAU,CAAC,EAAE;QACX,8CAA8C;QAC9C,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB,CAAA;CACF,CAAA;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,kBAAkB,SAAS,sBAAsB,EACxE,MAAM,GAAE,mBAAmB,CAAC,kBAAkB,CAAM,GACnD,aAAa,CAAC,kBAAkB,CAAC,CA+BnC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export * from "./declarations/src/index.js";
|
|
2
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1maWVsZHMtZHVyYXRpb24uY2pzLmQudHMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuL2RlY2xhcmF0aW9ucy9zcmMvaW5kZXguZC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSJ9
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var fields = require('@nixxie-cms/core/fields');
|
|
6
|
+
|
|
7
|
+
const SECONDS_PER_MINUTE = 60;
|
|
8
|
+
const SECONDS_PER_HOUR = 3600;
|
|
9
|
+
const SECONDS_PER_DAY = 86400;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format a duration in seconds as `"1h 30m 5s"`. Zero units are omitted (`3600` → `"1h"`),
|
|
13
|
+
* zero itself is `"0s"`, and negative durations get a leading `-`. Hours are not rolled up
|
|
14
|
+
* into days (`90000` → `"25h"`). Fractional input is rounded; non-finite input yields `''`.
|
|
15
|
+
*/
|
|
16
|
+
function formatDuration(seconds) {
|
|
17
|
+
if (!Number.isFinite(seconds)) return '';
|
|
18
|
+
const sign = seconds < 0 ? '-' : '';
|
|
19
|
+
const total = Math.round(Math.abs(seconds));
|
|
20
|
+
const h = Math.floor(total / SECONDS_PER_HOUR);
|
|
21
|
+
const m = Math.floor(total % SECONDS_PER_HOUR / SECONDS_PER_MINUTE);
|
|
22
|
+
const s = total % SECONDS_PER_MINUTE;
|
|
23
|
+
const parts = [];
|
|
24
|
+
if (h > 0) parts.push(`${h}h`);
|
|
25
|
+
if (m > 0) parts.push(`${m}m`);
|
|
26
|
+
if (s > 0) parts.push(`${s}s`);
|
|
27
|
+
if (parts.length === 0) return '0s';
|
|
28
|
+
return sign + parts.join(' ');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a human-entered duration into whole seconds. Dependency-free; accepts:
|
|
33
|
+
* - plain seconds: `"90"`
|
|
34
|
+
* - unit tokens: `"90s"`, `"1h 30m"`, `"2d 4h"` (units `d`, `h`, `m`, `s`, case-insensitive)
|
|
35
|
+
* - colon notation: `"1:30:05"` (h:mm:ss) or `"1:30"` (m:ss)
|
|
36
|
+
* - ISO-8601 durations: `"PT1H30M5S"`, `"P1DT12H"` (day/time components only)
|
|
37
|
+
*
|
|
38
|
+
* Fractional values are rounded to the nearest second. Returns `null` when unparseable.
|
|
39
|
+
*/
|
|
40
|
+
function parseDuration(input) {
|
|
41
|
+
const s = input.trim();
|
|
42
|
+
if (s === '') return null;
|
|
43
|
+
|
|
44
|
+
// Plain number of seconds, e.g. "90" or "90.5".
|
|
45
|
+
if (/^\d+(?:\.\d+)?$/.test(s)) return Math.round(parseFloat(s));
|
|
46
|
+
|
|
47
|
+
// ISO-8601 duration, limited to day/time components (P1DT2H3M4S). Years/months/weeks have
|
|
48
|
+
// no fixed length in seconds, so they are deliberately not supported.
|
|
49
|
+
const iso = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(s);
|
|
50
|
+
if (iso) {
|
|
51
|
+
const [, d, h, m, sec] = iso;
|
|
52
|
+
// Bare "P"/"PT" matches the regex but carries no components — reject it.
|
|
53
|
+
if (d === undefined && h === undefined && m === undefined && sec === undefined) return null;
|
|
54
|
+
return Math.round(parseFloat(d !== null && d !== void 0 ? d : '0') * SECONDS_PER_DAY + parseFloat(h !== null && h !== void 0 ? h : '0') * SECONDS_PER_HOUR + parseFloat(m !== null && m !== void 0 ? m : '0') * SECONDS_PER_MINUTE + parseFloat(sec !== null && sec !== void 0 ? sec : '0'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Colon notation: "1:30:05" (h:mm:ss) or "1:30" (m:ss).
|
|
58
|
+
const colon = /^(\d+):([0-5]?\d)(?::([0-5]?\d))?$/.exec(s);
|
|
59
|
+
if (colon) {
|
|
60
|
+
const [, a, b, c] = colon;
|
|
61
|
+
return c !== undefined ? parseInt(a, 10) * SECONDS_PER_HOUR + parseInt(b, 10) * SECONDS_PER_MINUTE + parseInt(c, 10) : parseInt(a, 10) * SECONDS_PER_MINUTE + parseInt(b, 10);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Unit tokens: "1h 30m 5s", "90s", "2d4h" — one or more value/unit pairs.
|
|
65
|
+
if (/^(?:\s*\d+(?:\.\d+)?\s*[dhms]\s*)+$/i.test(s)) {
|
|
66
|
+
const multipliers = {
|
|
67
|
+
d: SECONDS_PER_DAY,
|
|
68
|
+
h: SECONDS_PER_HOUR,
|
|
69
|
+
m: SECONDS_PER_MINUTE,
|
|
70
|
+
s: 1
|
|
71
|
+
};
|
|
72
|
+
let total = 0;
|
|
73
|
+
const token = /(\d+(?:\.\d+)?)\s*([dhms])/gi;
|
|
74
|
+
let match;
|
|
75
|
+
while (match = token.exec(s)) {
|
|
76
|
+
total += parseFloat(match[1]) * multipliers[match[2].toLowerCase()];
|
|
77
|
+
}
|
|
78
|
+
return Math.round(total);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A duration field storing a whole number of SECONDS. Built on the core `integer` field with
|
|
84
|
+
* optional `min`/`max` bounds enforced via a validate hook. Use `formatDuration` /
|
|
85
|
+
* `parseDuration` to convert to and from human-readable forms.
|
|
86
|
+
*/
|
|
87
|
+
function duration(config = {}) {
|
|
88
|
+
const {
|
|
89
|
+
min,
|
|
90
|
+
max,
|
|
91
|
+
hooks,
|
|
92
|
+
validation,
|
|
93
|
+
...rest
|
|
94
|
+
} = config;
|
|
95
|
+
const userValidate = hooks === null || hooks === void 0 ? void 0 : hooks.validate;
|
|
96
|
+
return fields.integer({
|
|
97
|
+
...rest,
|
|
98
|
+
validation: {
|
|
99
|
+
isRequired: validation === null || validation === void 0 ? void 0 : validation.isRequired
|
|
100
|
+
},
|
|
101
|
+
ui: {
|
|
102
|
+
description: 'Duration in seconds',
|
|
103
|
+
...rest.ui
|
|
104
|
+
},
|
|
105
|
+
hooks: {
|
|
106
|
+
...hooks,
|
|
107
|
+
validate: async args => {
|
|
108
|
+
var _userValidate;
|
|
109
|
+
// Preserve any user-supplied validate hook (function or { create, update, delete } form).
|
|
110
|
+
if (typeof userValidate === 'function') await userValidate(args);else await (userValidate === null || userValidate === void 0 || (_userValidate = userValidate[args.operation]) === null || _userValidate === void 0 ? void 0 : _userValidate.call(userValidate, args));
|
|
111
|
+
const {
|
|
112
|
+
resolvedData,
|
|
113
|
+
fieldKey,
|
|
114
|
+
addValidationError,
|
|
115
|
+
operation
|
|
116
|
+
} = args;
|
|
117
|
+
if (operation === 'delete') return;
|
|
118
|
+
const value = resolvedData[fieldKey];
|
|
119
|
+
if (typeof value !== 'number') return;
|
|
120
|
+
if (min !== undefined && value < min) {
|
|
121
|
+
addValidationError(`${fieldKey} must be at least ${min} seconds (${formatDuration(min)})`);
|
|
122
|
+
}
|
|
123
|
+
if (max !== undefined && value > max) {
|
|
124
|
+
addValidationError(`${fieldKey} must be at most ${max} seconds (${formatDuration(max)})`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
exports.duration = duration;
|
|
132
|
+
exports.formatDuration = formatDuration;
|
|
133
|
+
exports.parseDuration = parseDuration;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { integer } from '@nixxie-cms/core/fields';
|
|
2
|
+
|
|
3
|
+
const SECONDS_PER_MINUTE = 60;
|
|
4
|
+
const SECONDS_PER_HOUR = 3600;
|
|
5
|
+
const SECONDS_PER_DAY = 86400;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format a duration in seconds as `"1h 30m 5s"`. Zero units are omitted (`3600` → `"1h"`),
|
|
9
|
+
* zero itself is `"0s"`, and negative durations get a leading `-`. Hours are not rolled up
|
|
10
|
+
* into days (`90000` → `"25h"`). Fractional input is rounded; non-finite input yields `''`.
|
|
11
|
+
*/
|
|
12
|
+
function formatDuration(seconds) {
|
|
13
|
+
if (!Number.isFinite(seconds)) return '';
|
|
14
|
+
const sign = seconds < 0 ? '-' : '';
|
|
15
|
+
const total = Math.round(Math.abs(seconds));
|
|
16
|
+
const h = Math.floor(total / SECONDS_PER_HOUR);
|
|
17
|
+
const m = Math.floor(total % SECONDS_PER_HOUR / SECONDS_PER_MINUTE);
|
|
18
|
+
const s = total % SECONDS_PER_MINUTE;
|
|
19
|
+
const parts = [];
|
|
20
|
+
if (h > 0) parts.push(`${h}h`);
|
|
21
|
+
if (m > 0) parts.push(`${m}m`);
|
|
22
|
+
if (s > 0) parts.push(`${s}s`);
|
|
23
|
+
if (parts.length === 0) return '0s';
|
|
24
|
+
return sign + parts.join(' ');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a human-entered duration into whole seconds. Dependency-free; accepts:
|
|
29
|
+
* - plain seconds: `"90"`
|
|
30
|
+
* - unit tokens: `"90s"`, `"1h 30m"`, `"2d 4h"` (units `d`, `h`, `m`, `s`, case-insensitive)
|
|
31
|
+
* - colon notation: `"1:30:05"` (h:mm:ss) or `"1:30"` (m:ss)
|
|
32
|
+
* - ISO-8601 durations: `"PT1H30M5S"`, `"P1DT12H"` (day/time components only)
|
|
33
|
+
*
|
|
34
|
+
* Fractional values are rounded to the nearest second. Returns `null` when unparseable.
|
|
35
|
+
*/
|
|
36
|
+
function parseDuration(input) {
|
|
37
|
+
const s = input.trim();
|
|
38
|
+
if (s === '') return null;
|
|
39
|
+
|
|
40
|
+
// Plain number of seconds, e.g. "90" or "90.5".
|
|
41
|
+
if (/^\d+(?:\.\d+)?$/.test(s)) return Math.round(parseFloat(s));
|
|
42
|
+
|
|
43
|
+
// ISO-8601 duration, limited to day/time components (P1DT2H3M4S). Years/months/weeks have
|
|
44
|
+
// no fixed length in seconds, so they are deliberately not supported.
|
|
45
|
+
const iso = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(s);
|
|
46
|
+
if (iso) {
|
|
47
|
+
const [, d, h, m, sec] = iso;
|
|
48
|
+
// Bare "P"/"PT" matches the regex but carries no components — reject it.
|
|
49
|
+
if (d === undefined && h === undefined && m === undefined && sec === undefined) return null;
|
|
50
|
+
return Math.round(parseFloat(d !== null && d !== void 0 ? d : '0') * SECONDS_PER_DAY + parseFloat(h !== null && h !== void 0 ? h : '0') * SECONDS_PER_HOUR + parseFloat(m !== null && m !== void 0 ? m : '0') * SECONDS_PER_MINUTE + parseFloat(sec !== null && sec !== void 0 ? sec : '0'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Colon notation: "1:30:05" (h:mm:ss) or "1:30" (m:ss).
|
|
54
|
+
const colon = /^(\d+):([0-5]?\d)(?::([0-5]?\d))?$/.exec(s);
|
|
55
|
+
if (colon) {
|
|
56
|
+
const [, a, b, c] = colon;
|
|
57
|
+
return c !== undefined ? parseInt(a, 10) * SECONDS_PER_HOUR + parseInt(b, 10) * SECONDS_PER_MINUTE + parseInt(c, 10) : parseInt(a, 10) * SECONDS_PER_MINUTE + parseInt(b, 10);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Unit tokens: "1h 30m 5s", "90s", "2d4h" — one or more value/unit pairs.
|
|
61
|
+
if (/^(?:\s*\d+(?:\.\d+)?\s*[dhms]\s*)+$/i.test(s)) {
|
|
62
|
+
const multipliers = {
|
|
63
|
+
d: SECONDS_PER_DAY,
|
|
64
|
+
h: SECONDS_PER_HOUR,
|
|
65
|
+
m: SECONDS_PER_MINUTE,
|
|
66
|
+
s: 1
|
|
67
|
+
};
|
|
68
|
+
let total = 0;
|
|
69
|
+
const token = /(\d+(?:\.\d+)?)\s*([dhms])/gi;
|
|
70
|
+
let match;
|
|
71
|
+
while (match = token.exec(s)) {
|
|
72
|
+
total += parseFloat(match[1]) * multipliers[match[2].toLowerCase()];
|
|
73
|
+
}
|
|
74
|
+
return Math.round(total);
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A duration field storing a whole number of SECONDS. Built on the core `integer` field with
|
|
80
|
+
* optional `min`/`max` bounds enforced via a validate hook. Use `formatDuration` /
|
|
81
|
+
* `parseDuration` to convert to and from human-readable forms.
|
|
82
|
+
*/
|
|
83
|
+
function duration(config = {}) {
|
|
84
|
+
const {
|
|
85
|
+
min,
|
|
86
|
+
max,
|
|
87
|
+
hooks,
|
|
88
|
+
validation,
|
|
89
|
+
...rest
|
|
90
|
+
} = config;
|
|
91
|
+
const userValidate = hooks === null || hooks === void 0 ? void 0 : hooks.validate;
|
|
92
|
+
return integer({
|
|
93
|
+
...rest,
|
|
94
|
+
validation: {
|
|
95
|
+
isRequired: validation === null || validation === void 0 ? void 0 : validation.isRequired
|
|
96
|
+
},
|
|
97
|
+
ui: {
|
|
98
|
+
description: 'Duration in seconds',
|
|
99
|
+
...rest.ui
|
|
100
|
+
},
|
|
101
|
+
hooks: {
|
|
102
|
+
...hooks,
|
|
103
|
+
validate: async args => {
|
|
104
|
+
var _userValidate;
|
|
105
|
+
// Preserve any user-supplied validate hook (function or { create, update, delete } form).
|
|
106
|
+
if (typeof userValidate === 'function') await userValidate(args);else await (userValidate === null || userValidate === void 0 || (_userValidate = userValidate[args.operation]) === null || _userValidate === void 0 ? void 0 : _userValidate.call(userValidate, args));
|
|
107
|
+
const {
|
|
108
|
+
resolvedData,
|
|
109
|
+
fieldKey,
|
|
110
|
+
addValidationError,
|
|
111
|
+
operation
|
|
112
|
+
} = args;
|
|
113
|
+
if (operation === 'delete') return;
|
|
114
|
+
const value = resolvedData[fieldKey];
|
|
115
|
+
if (typeof value !== 'number') return;
|
|
116
|
+
if (min !== undefined && value < min) {
|
|
117
|
+
addValidationError(`${fieldKey} must be at least ${min} seconds (${formatDuration(min)})`);
|
|
118
|
+
}
|
|
119
|
+
if (max !== undefined && value > max) {
|
|
120
|
+
addValidationError(`${fieldKey} must be at most ${max} seconds (${formatDuration(max)})`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { duration, formatDuration, parseDuration };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nixxie-cms/fields-duration",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/nixxie-cms-fields-duration.cjs.js",
|
|
6
|
+
"module": "dist/nixxie-cms-fields-duration.esm.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/nixxie-cms-fields-duration.cjs.js",
|
|
10
|
+
"module": "./dist/nixxie-cms-fields-duration.esm.js",
|
|
11
|
+
"default": "./dist/nixxie-cms-fields-duration.cjs.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@babel/runtime": "^7.24.7"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@nixxie-cms/core": "^1.1.0"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
23
|
+
},
|
|
24
|
+
"preconstruct": {
|
|
25
|
+
"entrypoints": [
|
|
26
|
+
"index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/nixxiecms/nixxie/tree/main/packages/fields-duration"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { integer } from '@nixxie-cms/core/fields'
|
|
2
|
+
import type { IntegerFieldConfig } from '@nixxie-cms/core/fields'
|
|
3
|
+
import type { BaseCollectionTypeInfo, FieldTypeFunc } from '@nixxie-cms/core/types'
|
|
4
|
+
|
|
5
|
+
const SECONDS_PER_MINUTE = 60
|
|
6
|
+
const SECONDS_PER_HOUR = 3600
|
|
7
|
+
const SECONDS_PER_DAY = 86400
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Format a duration in seconds as `"1h 30m 5s"`. Zero units are omitted (`3600` → `"1h"`),
|
|
11
|
+
* zero itself is `"0s"`, and negative durations get a leading `-`. Hours are not rolled up
|
|
12
|
+
* into days (`90000` → `"25h"`). Fractional input is rounded; non-finite input yields `''`.
|
|
13
|
+
*/
|
|
14
|
+
export function formatDuration(seconds: number): string {
|
|
15
|
+
if (!Number.isFinite(seconds)) return ''
|
|
16
|
+
const sign = seconds < 0 ? '-' : ''
|
|
17
|
+
const total = Math.round(Math.abs(seconds))
|
|
18
|
+
const h = Math.floor(total / SECONDS_PER_HOUR)
|
|
19
|
+
const m = Math.floor((total % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE)
|
|
20
|
+
const s = total % SECONDS_PER_MINUTE
|
|
21
|
+
const parts: string[] = []
|
|
22
|
+
if (h > 0) parts.push(`${h}h`)
|
|
23
|
+
if (m > 0) parts.push(`${m}m`)
|
|
24
|
+
if (s > 0) parts.push(`${s}s`)
|
|
25
|
+
if (parts.length === 0) return '0s'
|
|
26
|
+
return sign + parts.join(' ')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a human-entered duration into whole seconds. Dependency-free; accepts:
|
|
31
|
+
* - plain seconds: `"90"`
|
|
32
|
+
* - unit tokens: `"90s"`, `"1h 30m"`, `"2d 4h"` (units `d`, `h`, `m`, `s`, case-insensitive)
|
|
33
|
+
* - colon notation: `"1:30:05"` (h:mm:ss) or `"1:30"` (m:ss)
|
|
34
|
+
* - ISO-8601 durations: `"PT1H30M5S"`, `"P1DT12H"` (day/time components only)
|
|
35
|
+
*
|
|
36
|
+
* Fractional values are rounded to the nearest second. Returns `null` when unparseable.
|
|
37
|
+
*/
|
|
38
|
+
export function parseDuration(input: string): number | null {
|
|
39
|
+
const s = input.trim()
|
|
40
|
+
if (s === '') return null
|
|
41
|
+
|
|
42
|
+
// Plain number of seconds, e.g. "90" or "90.5".
|
|
43
|
+
if (/^\d+(?:\.\d+)?$/.test(s)) return Math.round(parseFloat(s))
|
|
44
|
+
|
|
45
|
+
// ISO-8601 duration, limited to day/time components (P1DT2H3M4S). Years/months/weeks have
|
|
46
|
+
// no fixed length in seconds, so they are deliberately not supported.
|
|
47
|
+
const iso = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i.exec(s)
|
|
48
|
+
if (iso) {
|
|
49
|
+
const [, d, h, m, sec] = iso
|
|
50
|
+
// Bare "P"/"PT" matches the regex but carries no components — reject it.
|
|
51
|
+
if (d === undefined && h === undefined && m === undefined && sec === undefined) return null
|
|
52
|
+
return Math.round(
|
|
53
|
+
parseFloat(d ?? '0') * SECONDS_PER_DAY +
|
|
54
|
+
parseFloat(h ?? '0') * SECONDS_PER_HOUR +
|
|
55
|
+
parseFloat(m ?? '0') * SECONDS_PER_MINUTE +
|
|
56
|
+
parseFloat(sec ?? '0')
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Colon notation: "1:30:05" (h:mm:ss) or "1:30" (m:ss).
|
|
61
|
+
const colon = /^(\d+):([0-5]?\d)(?::([0-5]?\d))?$/.exec(s)
|
|
62
|
+
if (colon) {
|
|
63
|
+
const [, a, b, c] = colon
|
|
64
|
+
return c !== undefined
|
|
65
|
+
? parseInt(a, 10) * SECONDS_PER_HOUR + parseInt(b, 10) * SECONDS_PER_MINUTE + parseInt(c, 10)
|
|
66
|
+
: parseInt(a, 10) * SECONDS_PER_MINUTE + parseInt(b, 10)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Unit tokens: "1h 30m 5s", "90s", "2d4h" — one or more value/unit pairs.
|
|
70
|
+
if (/^(?:\s*\d+(?:\.\d+)?\s*[dhms]\s*)+$/i.test(s)) {
|
|
71
|
+
const multipliers: Record<string, number> = {
|
|
72
|
+
d: SECONDS_PER_DAY,
|
|
73
|
+
h: SECONDS_PER_HOUR,
|
|
74
|
+
m: SECONDS_PER_MINUTE,
|
|
75
|
+
s: 1,
|
|
76
|
+
}
|
|
77
|
+
let total = 0
|
|
78
|
+
const token = /(\d+(?:\.\d+)?)\s*([dhms])/gi
|
|
79
|
+
let match: RegExpExecArray | null
|
|
80
|
+
while ((match = token.exec(s))) {
|
|
81
|
+
total += parseFloat(match[1]) * multipliers[match[2].toLowerCase()]
|
|
82
|
+
}
|
|
83
|
+
return Math.round(total)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type DurationFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<
|
|
90
|
+
IntegerFieldConfig<CollectionTypeInfo>,
|
|
91
|
+
'validation'
|
|
92
|
+
> & {
|
|
93
|
+
/** Smallest allowed duration, in seconds. */
|
|
94
|
+
min?: number
|
|
95
|
+
/** Largest allowed duration, in seconds. */
|
|
96
|
+
max?: number
|
|
97
|
+
validation?: {
|
|
98
|
+
/** Reject saving when no value is present. */
|
|
99
|
+
isRequired?: boolean
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A duration field storing a whole number of SECONDS. Built on the core `integer` field with
|
|
105
|
+
* optional `min`/`max` bounds enforced via a validate hook. Use `formatDuration` /
|
|
106
|
+
* `parseDuration` to convert to and from human-readable forms.
|
|
107
|
+
*/
|
|
108
|
+
export function duration<CollectionTypeInfo extends BaseCollectionTypeInfo>(
|
|
109
|
+
config: DurationFieldConfig<CollectionTypeInfo> = {}
|
|
110
|
+
): FieldTypeFunc<CollectionTypeInfo> {
|
|
111
|
+
const { min, max, hooks, validation, ...rest } = config
|
|
112
|
+
const userValidate = hooks?.validate as
|
|
113
|
+
| ((args: any) => unknown)
|
|
114
|
+
| { create?: (args: any) => unknown; update?: (args: any) => unknown; delete?: (args: any) => unknown }
|
|
115
|
+
| undefined
|
|
116
|
+
|
|
117
|
+
return integer<CollectionTypeInfo>({
|
|
118
|
+
...(rest as IntegerFieldConfig<CollectionTypeInfo>),
|
|
119
|
+
validation: { isRequired: validation?.isRequired },
|
|
120
|
+
ui: { description: 'Duration in seconds', ...rest.ui },
|
|
121
|
+
hooks: {
|
|
122
|
+
...hooks,
|
|
123
|
+
validate: async (args: any) => {
|
|
124
|
+
// Preserve any user-supplied validate hook (function or { create, update, delete } form).
|
|
125
|
+
if (typeof userValidate === 'function') await userValidate(args)
|
|
126
|
+
else await userValidate?.[args.operation as 'create' | 'update' | 'delete']?.(args)
|
|
127
|
+
|
|
128
|
+
const { resolvedData, fieldKey, addValidationError, operation } = args
|
|
129
|
+
if (operation === 'delete') return
|
|
130
|
+
const value = resolvedData[fieldKey]
|
|
131
|
+
if (typeof value !== 'number') return
|
|
132
|
+
if (min !== undefined && value < min) {
|
|
133
|
+
addValidationError(`${fieldKey} must be at least ${min} seconds (${formatDuration(min)})`)
|
|
134
|
+
}
|
|
135
|
+
if (max !== undefined && value > max) {
|
|
136
|
+
addValidationError(`${fieldKey} must be at most ${max} seconds (${formatDuration(max)})`)
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
}
|