cronli5 0.2.1 → 0.3.4
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 +109 -0
- package/README.md +4 -4
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +471 -383
- package/dist/cronli5.js +471 -383
- package/dist/lang/de.cjs +286 -215
- package/dist/lang/de.js +286 -215
- package/dist/lang/en.cjs +413 -327
- package/dist/lang/en.js +413 -327
- package/dist/lang/es.cjs +303 -265
- package/dist/lang/es.js +303 -265
- package/dist/lang/fi.cjs +311 -266
- package/dist/lang/fi.js +311 -266
- package/dist/lang/zh.cjs +320 -240
- package/dist/lang/zh.js +320 -240
- package/package.json +6 -6
- package/src/core/analyze.ts +12 -12
- package/src/core/cadence.ts +164 -0
- package/src/core/index.ts +3 -1
- package/src/core/normalize.ts +3 -3
- package/src/core/parse.ts +1 -1
- package/src/core/{ir.ts → schedule.ts} +17 -18
- package/src/core/specs.ts +1 -1
- package/src/core/util.ts +3 -165
- package/src/core/validate.ts +1 -1
- package/src/core/weekday.ts +54 -0
- package/src/cronli5.ts +5 -5
- package/src/lang/de/index.ts +329 -219
- package/src/lang/en/dialects.ts +1 -1
- package/src/lang/en/index.ts +521 -372
- package/src/lang/es/index.ts +338 -286
- package/src/lang/es/notes.md +1 -1
- package/src/lang/fi/dialects.ts +1 -1
- package/src/lang/fi/index.ts +365 -299
- package/src/lang/fi/notes.md +23 -8
- package/src/lang/fi/status.json +1 -1
- package/src/lang/zh/index.ts +386 -245
- package/src/types.ts +6 -6
- package/types/core/analyze.d.ts +3 -3
- package/types/core/cadence.d.ts +33 -0
- package/types/core/index.d.ts +3 -1
- package/types/core/normalize.d.ts +1 -1
- package/types/core/parse.d.ts +1 -1
- package/types/core/{ir.d.ts → schedule.d.ts} +11 -16
- package/types/core/specs.d.ts +1 -1
- package/types/core/util.d.ts +1 -30
- package/types/core/weekday.d.ts +10 -0
- package/types/lang/de/index.d.ts +1 -1
- package/types/lang/en/dialects.d.ts +1 -1
- package/types/lang/en/index.d.ts +1 -1
- package/types/lang/es/index.d.ts +1 -1
- package/types/lang/fi/dialects.d.ts +1 -1
- package/types/lang/fi/index.d.ts +1 -1
- package/types/lang/zh/index.d.ts +1 -1
- package/types/types.d.ts +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cronli5",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Cron Like I'm Five: A Cron to English Utility",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -65,11 +65,11 @@
|
|
|
65
65
|
"lint": "eslint src test cli.js eslint.config.js scripts tooling/scripts",
|
|
66
66
|
"metamorphic": "node --import tsx tooling/scripts/metamorphic.mjs",
|
|
67
67
|
"lint:fix": "eslint src test cli.js eslint.config.js scripts tooling/scripts --fix",
|
|
68
|
-
"test": "
|
|
68
|
+
"test": "vitest run",
|
|
69
69
|
"types": "tsc -p tsconfig.types.json",
|
|
70
70
|
"test:types": "npm run types && tsd",
|
|
71
71
|
"typecheck": "tsc -p tsconfig.json",
|
|
72
|
-
"coverage": "
|
|
72
|
+
"coverage": "vitest run --coverage",
|
|
73
73
|
"verify": "npm run lint && npm run typecheck && npm run test:types && npm test && npm run coverage && npm run conciseness && npm run docs -- --check && npm run build",
|
|
74
74
|
"prepare": "node scripts/install-hooks.mjs",
|
|
75
75
|
"prepublishOnly": "npm run lint && npm run typecheck && npm run test:types && npm run build && npm test"
|
|
@@ -90,18 +90,18 @@
|
|
|
90
90
|
"devDependencies": {
|
|
91
91
|
"@eslint/eslintrc": "^3.2.0",
|
|
92
92
|
"@eslint/js": "^9.15.0",
|
|
93
|
-
"
|
|
93
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
94
94
|
"chai": "^4.5.0",
|
|
95
95
|
"cronstrue": "^3.14.0",
|
|
96
96
|
"esbuild": "^0.28.1",
|
|
97
97
|
"eslint": "^9.15.0",
|
|
98
98
|
"fast-check": "^3.23.1",
|
|
99
99
|
"globals": "^15.12.0",
|
|
100
|
-
"mocha": "^11.0.1",
|
|
101
100
|
"tsd": "^0.31.2",
|
|
102
101
|
"tsx": "^4.22.4",
|
|
103
102
|
"typescript": "^6.0.3",
|
|
104
|
-
"typescript-eslint": "^8.61.0"
|
|
103
|
+
"typescript-eslint": "^8.61.0",
|
|
104
|
+
"vitest": "^4.1.9"
|
|
105
105
|
},
|
|
106
106
|
"tsd": {
|
|
107
107
|
"directory": "test-d"
|
package/src/core/analyze.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// Semantic analysis of canonical fields: fire enumeration, windows, shape
|
|
2
2
|
// classification, and description-plan selection (the `plan`). The
|
|
3
|
-
// resulting
|
|
3
|
+
// resulting Schedule is descriptive. Language modules handle rendering into
|
|
4
4
|
// words (docs/i18n-design.md §2.2).
|
|
5
5
|
|
|
6
6
|
import {fieldOrder, fieldSpecs, maxClockTimes} from './specs.js';
|
|
7
7
|
import type {FieldSpec} from './specs.js';
|
|
8
8
|
import type {
|
|
9
|
-
Analyses, ClockTime,
|
|
10
|
-
PlanNode, Segment, Shape, Shapes
|
|
11
|
-
} from './
|
|
9
|
+
Analyses, ClockTime, Field, HourTimesPlan, HoursPlan, Pattern,
|
|
10
|
+
PlanNode, Schedule, ScheduleFacts, Segment, Shape, Shapes
|
|
11
|
+
} from './schedule.js';
|
|
12
12
|
import {includes, toFieldNumber, unique} from './util.js';
|
|
13
13
|
import {isDiscreteHours, isDiscreteList, isPlainRange, isSingleValue}
|
|
14
14
|
from './shapes.js';
|
|
@@ -198,8 +198,8 @@ function fieldSegments(
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
// Analyze a prepared (parsed, validated, normalized) cron pattern into the
|
|
201
|
-
//
|
|
202
|
-
function analyze(pattern: Pattern):
|
|
201
|
+
// Schedule a language module renders from.
|
|
202
|
+
function analyze(pattern: Pattern): Schedule {
|
|
203
203
|
const shapes = {} as Shapes;
|
|
204
204
|
const segments = {} as Analyses['segments'];
|
|
205
205
|
|
|
@@ -216,18 +216,18 @@ function analyze(pattern: Pattern): IR {
|
|
|
216
216
|
segments
|
|
217
217
|
};
|
|
218
218
|
|
|
219
|
-
const
|
|
219
|
+
const facts: ScheduleFacts = {analyses, pattern, shapes};
|
|
220
220
|
|
|
221
|
-
return {...
|
|
221
|
+
return {...facts, plan: selectPlan(facts)};
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
// Select the description plan from the neutral
|
|
224
|
+
// Select the description plan from the neutral facts. This is the
|
|
225
225
|
// core's *suggestion*: a language may override it via `Language.plan`
|
|
226
|
-
// without re-deriving it (the
|
|
226
|
+
// without re-deriving it (the facts / overridable-plan split).
|
|
227
227
|
// The selection mirrors the interpreter chain ordering exactly; renderers
|
|
228
228
|
// must not re-derive it.
|
|
229
|
-
function selectPlan(
|
|
230
|
-
const {analyses, pattern, shapes} =
|
|
229
|
+
function selectPlan(facts: ScheduleFacts): PlanNode {
|
|
230
|
+
const {analyses, pattern, shapes} = facts;
|
|
231
231
|
|
|
232
232
|
if (pattern.second !== '0') {
|
|
233
233
|
const seconds = planSeconds(pattern, shapes, analyses);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Cadence analysis: recognizing arithmetic progressions and step strides in a
|
|
2
|
+
// field's values, and the segment accessors renderers use to reach them.
|
|
3
|
+
// Output-neutral and language-agnostic; renderers speak the cadence they find.
|
|
4
|
+
|
|
5
|
+
import type {Field, Schedule, Segment} from './schedule.js';
|
|
6
|
+
|
|
7
|
+
// A step segment of a classified field, carrying its `fires`/`interval`/
|
|
8
|
+
// `startToken`. The plan only routes step-shaped fields to step phrasing,
|
|
9
|
+
// where the first segment is always a step segment.
|
|
10
|
+
type StepSegment = Extract<Segment, {kind: 'step'}>;
|
|
11
|
+
|
|
12
|
+
// Recognize an arithmetic progression in a sorted, distinct numeric set: a
|
|
13
|
+
// run of length >= 5 whose consecutive gaps are all equal and >= 2. Returns
|
|
14
|
+
// its {start, interval, last}; null for anything shorter, with a gap of one
|
|
15
|
+
// (a plain run, which reads as a range), or irregular. Output-neutral and
|
|
16
|
+
// language-agnostic: renderers use it to speak a bounded/offset step cadence
|
|
17
|
+
// ("every N from M [through K]") instead of enumerating the fires. The set is
|
|
18
|
+
// the field's full value list, which the core has already sorted and deduped.
|
|
19
|
+
function arithmeticStep(values: number[]):
|
|
20
|
+
{start: number; interval: number; last: number} | null {
|
|
21
|
+
if (values.length < 5) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const interval = values[1] - values[0];
|
|
26
|
+
|
|
27
|
+
if (interval < 2) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (let i = 2; i < values.length; i += 1) {
|
|
32
|
+
if (values[i] - values[i - 1] !== interval) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {start: values[0], interval, last: values[values.length - 1]};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A field's classified segments, or an empty list when the field is a
|
|
41
|
+
// wildcard or Quartz shape (no segments). Renderers reach a non-empty list
|
|
42
|
+
// only on the field shapes the analysis segmented; the empty fallback keeps
|
|
43
|
+
// callers that touch a possibly-unsegmented field (a `.map`/`.forEach`) safe.
|
|
44
|
+
function segmentsOf(schedule: Schedule, field: Field): Segment[] {
|
|
45
|
+
return schedule.analyses.segments[field] ?? [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The first segment of a step field, narrowed to its step variant. The plan
|
|
49
|
+
// only routes step shapes here, whose (single) segment always classifies as a
|
|
50
|
+
// step; this asserts what the analysis guarantees but the type cannot express.
|
|
51
|
+
function stepSegment(schedule: Schedule, field: Field): StepSegment {
|
|
52
|
+
return segmentsOf(schedule, field)[0] as StepSegment;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The sorted numeric values a field's segments cover, or null if any segment
|
|
56
|
+
// is not a discrete single (a range or sub-step is not a plain fire list).
|
|
57
|
+
function singleValues(segments: Segment[]): number[] | null {
|
|
58
|
+
const values: number[] = [];
|
|
59
|
+
|
|
60
|
+
for (const segment of segments) {
|
|
61
|
+
if (segment.kind !== 'single') {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
values.push(+segment.value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return values;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Whether an hour stride wraps the day cleanly from within its first interval
|
|
72
|
+
// (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
|
|
73
|
+
// stride has no distinct endpoint and keeps its bare or "from M" cadence. Every
|
|
74
|
+
// other stride — a uneven interval, or one starting at or past its interval (a
|
|
75
|
+
// bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
|
|
76
|
+
function offsetCleanStride(
|
|
77
|
+
stride: {start: number; interval: number}
|
|
78
|
+
): boolean {
|
|
79
|
+
return stride.start < stride.interval && 24 % stride.interval === 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The three branches of the stride/cadence decision tree, supplied by a
|
|
83
|
+
// renderer as its own words. The core picks the branch; the language owns the
|
|
84
|
+
// prose. `bare` is the clean cadence ("every N <unit>"); `offset` names only
|
|
85
|
+
// the start (a clean wrap with no distinct endpoint); `bounded` pins both
|
|
86
|
+
// endpoints (a non-wrapping set). Each is a thunk so the renderer evaluates
|
|
87
|
+
// only the branch the core selects (e.g. a stateful bound formatter is never
|
|
88
|
+
// constructed for the bare case). The words differ per language — case
|
|
89
|
+
// inflection, a measure word, a trailing idiom — so the leaves stay here, not
|
|
90
|
+
// in core.
|
|
91
|
+
interface StrideParts {
|
|
92
|
+
bare(): string;
|
|
93
|
+
offset(): string;
|
|
94
|
+
bounded(): string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Choose the stride/cadence branch for a step over a `cycle`-long field and
|
|
98
|
+
// emit the renderer's words for it. A clean stride from the top of the cycle
|
|
99
|
+
// is the bare cadence; a uniform offset (start within the first interval, the
|
|
100
|
+
// interval still tiling the cycle) names only its start, since it wraps cleanly
|
|
101
|
+
// with no distinct endpoint; a non-uniform stride (start >= interval, or an
|
|
102
|
+
// interval that does not tile the cycle) pins both endpoints so the bounded,
|
|
103
|
+
// non-wrapping set reads unambiguously. This is the one decision tree every
|
|
104
|
+
// renderer's `renderStride`/`hourStrideCadence` shared (cycle 60 for
|
|
105
|
+
// minute/second, 24 for the hour); the branch lives here once, the prose in
|
|
106
|
+
// each language's `parts`.
|
|
107
|
+
function renderStride(
|
|
108
|
+
spec: {start: number; interval: number; cycle: number},
|
|
109
|
+
parts: StrideParts
|
|
110
|
+
): string {
|
|
111
|
+
const {start, interval, cycle} = spec;
|
|
112
|
+
const tiles = cycle % interval === 0;
|
|
113
|
+
|
|
114
|
+
if (start === 0 && tiles) {
|
|
115
|
+
return parts.bare();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (start < interval && tiles) {
|
|
119
|
+
return parts.offset();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return parts.bounded();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// An hour list's arithmetic progression, or null when its values are not a
|
|
126
|
+
// step the renderer should speak as a cadence. The core rewrites a uneven hour
|
|
127
|
+
// step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
|
|
128
|
+
// literal fire list, indistinguishable in the Schedule from a hand-written
|
|
129
|
+
// list; the renderer recovers the cadence from the values. A progression
|
|
130
|
+
// starting at zero is a `*/n` step however short (0,7,14,21 is `*/7`); a
|
|
131
|
+
// non-zero one is only a step when it is too long to be a deliberate clock-time
|
|
132
|
+
// list (e.g. 9,17 is two named times, not a cadence), the same length the
|
|
133
|
+
// minute/second list path uses. Interval one is a plain range, never a step.
|
|
134
|
+
function hourListStride(
|
|
135
|
+
values: number[]
|
|
136
|
+
): {start: number; interval: number; last: number} | null {
|
|
137
|
+
if (values.length < 2) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const interval = values[1] - values[0];
|
|
142
|
+
|
|
143
|
+
if (interval < 2) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (let i = 2; i < values.length; i += 1) {
|
|
148
|
+
if (values[i] - values[i - 1] !== interval) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (values[0] !== 0 && values.length < 5) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {interval, last: values[values.length - 1], start: values[0]};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export {
|
|
161
|
+
arithmeticStep, hourListStride, offsetCleanStride, renderStride, segmentsOf,
|
|
162
|
+
singleValues, stepSegment
|
|
163
|
+
};
|
|
164
|
+
export type {StrideParts};
|
package/src/core/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// See docs/i18n-design.md.
|
|
5
5
|
|
|
6
6
|
import {applyQuartzAliases, normalizeCronPattern} from './normalize.js';
|
|
7
|
-
import type {NormalizedOptions, Pattern} from './
|
|
7
|
+
import type {NormalizedOptions, Pattern} from './schedule.js';
|
|
8
8
|
import {parseCronPattern} from './parse.js';
|
|
9
9
|
import type {CronPattern} from '../types.js';
|
|
10
10
|
import {validateCronPattern} from './validate.js';
|
|
@@ -24,5 +24,7 @@ function prepare(cronPattern: CronPattern, opts: NormalizedOptions): Pattern {
|
|
|
24
24
|
export {prepare};
|
|
25
25
|
export * from './specs.js';
|
|
26
26
|
export * from './util.js';
|
|
27
|
+
export * from './weekday.js';
|
|
28
|
+
export * from './cadence.js';
|
|
27
29
|
export * from './shapes.js';
|
|
28
30
|
export * from './analyze.js';
|
package/src/core/normalize.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import {fieldOrder, fieldSpecs} from './specs.js';
|
|
6
6
|
import type {CronLike, FieldSpec} from './specs.js';
|
|
7
|
-
import type {Field, Pattern} from './
|
|
7
|
+
import type {Field, Pattern} from './schedule.js';
|
|
8
8
|
import {includes, toFieldNumber, unique} from './util.js';
|
|
9
9
|
import {isQuartzDate, isQuartzWeekday} from './validate.js';
|
|
10
10
|
|
|
@@ -68,7 +68,7 @@ function normalizeField(value: string, field: Field, spec: FieldSpec): string {
|
|
|
68
68
|
const cycle = timeFieldCycle[field];
|
|
69
69
|
const segments = stringValue.split(',').map(function canonical(segment) {
|
|
70
70
|
return canonicalizeTokens(collapseFullSpanRange(
|
|
71
|
-
|
|
71
|
+
enumerateIfNonUniform(
|
|
72
72
|
collapseFullSpanStep(
|
|
73
73
|
collapseDegenerateRange(
|
|
74
74
|
collapseOnceStep(collapseUnitStep(segment, spec), spec), spec),
|
|
@@ -172,7 +172,7 @@ function collapseOnceStep(segment: string, spec: FieldSpec): string {
|
|
|
172
172
|
// list of those fires (`*/7` is `0,7,14,…`), the same as if it were written
|
|
173
173
|
// out. Calendar fields (no `cycle`), bounded steps (`9-17/2`, a per-window
|
|
174
174
|
// stride), and non-step segments are left untouched.
|
|
175
|
-
function
|
|
175
|
+
function enumerateIfNonUniform(
|
|
176
176
|
segment: string,
|
|
177
177
|
spec: FieldSpec,
|
|
178
178
|
cycle: number | undefined
|
package/src/core/parse.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {macros} from './specs.js';
|
|
4
4
|
import type {CronLike} from './specs.js';
|
|
5
|
-
import type {NormalizedOptions} from './
|
|
5
|
+
import type {NormalizedOptions} from './schedule.js';
|
|
6
6
|
import type {CronPattern, CronPatternObject} from '../types.js';
|
|
7
7
|
|
|
8
8
|
// Take a cron pattern as a cron pattern string, an array of cron fields, a
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// docs/i18n-design.md.
|
|
1
|
+
// A `Schedule` is the semantic contract between the core and a language
|
|
2
|
+
// renderer. The core (parse → validate → normalize → analyze) produces a
|
|
3
|
+
// `Schedule`, then a language module renders it to prose. These shapes are
|
|
4
|
+
// what any new language module interacts with. See docs/i18n-design.md.
|
|
6
5
|
|
|
7
6
|
import type {Cronli5Options} from '../types.js';
|
|
8
7
|
|
|
@@ -42,8 +41,7 @@ export type HoursPlan =
|
|
|
42
41
|
| {kind: 'none'}
|
|
43
42
|
| {kind: 'step'}
|
|
44
43
|
| {kind: 'window'; from: number; to: number; last: number}
|
|
45
|
-
| {kind: 'during'; times: HourTimesPlan}
|
|
46
|
-
| {kind: 'single'; from: number; to: number; last: number};
|
|
44
|
+
| {kind: 'during'; times: HourTimesPlan};
|
|
47
45
|
|
|
48
46
|
/** Hour times: enumerated fires, or deferred to per-segment rendering. */
|
|
49
47
|
export type HourTimesPlan =
|
|
@@ -99,24 +97,25 @@ export interface Analyses {
|
|
|
99
97
|
}
|
|
100
98
|
|
|
101
99
|
/**
|
|
102
|
-
* The neutral
|
|
100
|
+
* The neutral schedule facts: the language-independent facts about a pattern,
|
|
103
101
|
* carrying no phrasing decision. `analyze` produces this; `selectPlan`
|
|
104
102
|
* reads it to suggest a `plan`. The phrasing plan is deliberately *not*
|
|
105
|
-
* part of the neutral
|
|
103
|
+
* part of the neutral facts (docs/i18n-design.md §2.2).
|
|
106
104
|
*/
|
|
107
|
-
export interface
|
|
105
|
+
export interface ScheduleFacts {
|
|
108
106
|
pattern: Pattern;
|
|
109
107
|
shapes: Shapes;
|
|
110
108
|
analyses: Analyses;
|
|
111
109
|
}
|
|
112
110
|
|
|
113
111
|
/**
|
|
114
|
-
* The semantic
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
112
|
+
* The semantic schedule a language renders: the neutral `ScheduleFacts`
|
|
113
|
+
* plus the selected `plan`. A language may widen `plan` with its own
|
|
114
|
+
* `Extra` plan kinds via `Language.plan`; by default there are none, so
|
|
115
|
+
* `Schedule` is the neutral facts with a core `PlanNode`.
|
|
118
116
|
*/
|
|
119
|
-
export interface
|
|
117
|
+
export interface Schedule<Extra extends {kind: string} = never>
|
|
118
|
+
extends ScheduleFacts {
|
|
120
119
|
plan: PlanNode | Extra;
|
|
121
120
|
}
|
|
122
121
|
|
|
@@ -163,7 +162,7 @@ export interface Language<
|
|
|
163
162
|
Style = DialectStyle,
|
|
164
163
|
Extra extends {kind: string} = never
|
|
165
164
|
> {
|
|
166
|
-
describe(
|
|
165
|
+
describe(schedule: Schedule<Extra>, opts: NormalizedOptions<Style>): string;
|
|
167
166
|
fallback: string;
|
|
168
167
|
options(options?: Cronli5Options): NormalizedOptions<Style>;
|
|
169
168
|
reboot: string;
|
|
@@ -171,8 +170,8 @@ export interface Language<
|
|
|
171
170
|
// form); each language owns its lead verb and punctuation.
|
|
172
171
|
sentence(description: string): string;
|
|
173
172
|
// Optionally override the core's suggested plan. Receives the neutral
|
|
174
|
-
// `
|
|
173
|
+
// `facts` and the core's suggestion (`base`), so overriding is a thin
|
|
175
174
|
// remap, not a re-derivation. Omitted by languages that accept the core's
|
|
176
175
|
// choice (all of en/de/es/fi today).
|
|
177
|
-
plan?(
|
|
176
|
+
plan?(facts: ScheduleFacts, base: PlanNode): PlanNode | Extra;
|
|
178
177
|
}
|
package/src/core/specs.ts
CHANGED
package/src/core/util.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
import type {Field, IR, Segment} from './ir.js';
|
|
4
|
-
|
|
5
|
-
// A step segment of a classified field, carrying its `fires`/`interval`/
|
|
6
|
-
// `startToken`. The plan only routes step-shaped fields to step phrasing,
|
|
7
|
-
// where the first segment is always a step segment.
|
|
8
|
-
type StepSegment = Extract<Segment, {kind: 'step'}>;
|
|
1
|
+
// Generic pure helpers shared across the core.
|
|
9
2
|
|
|
3
|
+
// Whether a string contains a substring (coercing numbers to strings).
|
|
10
4
|
function includes(str: string | number, sub: string): boolean {
|
|
11
5
|
return ('' + str).indexOf(sub) !== -1;
|
|
12
6
|
}
|
|
@@ -23,82 +17,6 @@ function isNonNegativeInteger(value: string): boolean {
|
|
|
23
17
|
return digits.test(value);
|
|
24
18
|
}
|
|
25
19
|
|
|
26
|
-
// Recognize an arithmetic progression in a sorted, distinct numeric set: a
|
|
27
|
-
// run of length >= 5 whose consecutive gaps are all equal and >= 2. Returns
|
|
28
|
-
// its {start, interval, last}; null for anything shorter, with a gap of one
|
|
29
|
-
// (a plain run, which reads as a range), or irregular. Output-neutral and
|
|
30
|
-
// language-agnostic: renderers use it to speak a bounded/offset step cadence
|
|
31
|
-
// ("every N from M [through K]") instead of enumerating the fires. The set is
|
|
32
|
-
// the field's full value list, which the core has already sorted and deduped.
|
|
33
|
-
function arithmeticStep(values: number[]):
|
|
34
|
-
{start: number; interval: number; last: number} | null {
|
|
35
|
-
if (values.length < 5) {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const interval = values[1] - values[0];
|
|
40
|
-
|
|
41
|
-
if (interval < 2) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
for (let i = 2; i < values.length; i += 1) {
|
|
46
|
-
if (values[i] - values[i - 1] !== interval) {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return {start: values[0], interval, last: values[values.length - 1]};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// The display sort key for a canonical weekday number: Monday (1) first,
|
|
55
|
-
// Sunday (0) last. The IR keeps Sunday=0 canonical; this is display-only.
|
|
56
|
-
function weekdayDisplayKey(value: number): number {
|
|
57
|
-
return value === 0 ? 7 : value;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// A weekday display segment: a single day or a (possibly wrap) range. Steps
|
|
61
|
-
// are flattened away into singles before sorting, so the result is only these
|
|
62
|
-
// two kinds; each renderer turns them into names exactly as it does today.
|
|
63
|
-
type WeekdaySegment =
|
|
64
|
-
| {kind: 'single'; value: string}
|
|
65
|
-
| {kind: 'range'; bounds: [string, string]};
|
|
66
|
-
|
|
67
|
-
// Reorder weekday segments Monday-first (Sunday last) for display, so a weekend
|
|
68
|
-
// list reads "Saturday and Sunday" rather than the canonical Sunday-first
|
|
69
|
-
// "Sunday and Saturday". Display-only: the IR / canonical order is unchanged (a
|
|
70
|
-
// fresh array is returned). A step expands to its fires as singles so the days
|
|
71
|
-
// sort into the list; a range stays one unit and keeps its own bounds order (a
|
|
72
|
-
// wrap range is not reordered into a list), sorting by its opening bound — so a
|
|
73
|
-
// lone range sorts to a one-element list and is unchanged. The sort is stable,
|
|
74
|
-
// so equal opening days keep input order.
|
|
75
|
-
function orderWeekdaysForDisplay(segments: Segment[]): WeekdaySegment[] {
|
|
76
|
-
const flattened: WeekdaySegment[] = segments.flatMap(function flat(segment) {
|
|
77
|
-
return segment.kind === 'step' ?
|
|
78
|
-
segment.fires.map(function single(value): WeekdaySegment {
|
|
79
|
-
return {kind: 'single', value: '' + value};
|
|
80
|
-
}) :
|
|
81
|
-
[segment];
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
function key(segment: WeekdaySegment): number {
|
|
85
|
-
return segment.kind === 'range' ?
|
|
86
|
-
weekdayDisplayKey(+segment.bounds[0]) :
|
|
87
|
-
weekdayDisplayKey(+segment.value);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return flattened
|
|
91
|
-
.map(function index(segment, position): [WeekdaySegment, number] {
|
|
92
|
-
return [segment, position];
|
|
93
|
-
})
|
|
94
|
-
.sort(function byDisplayKey(a, b): number {
|
|
95
|
-
return key(a[0]) - key(b[0]) || a[1] - b[1];
|
|
96
|
-
})
|
|
97
|
-
.map(function unwrap(pair): WeekdaySegment {
|
|
98
|
-
return pair[0];
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
20
|
// Resolve a numeric or named field token (e.g. '5' or 'FRI') to its number.
|
|
103
21
|
function toFieldNumber(
|
|
104
22
|
token: string,
|
|
@@ -108,85 +26,5 @@ function toFieldNumber(
|
|
|
108
26
|
// weekday) reach here. They always have an associated `numberMap`.
|
|
109
27
|
return isNonNegativeInteger(token) ? +token : numberMap![token.toUpperCase()];
|
|
110
28
|
}
|
|
111
|
-
// A field's classified segments, or an empty list when the field is a
|
|
112
|
-
// wildcard or Quartz shape (no segments). Renderers reach a non-empty list
|
|
113
|
-
// only on the field shapes the analysis segmented; the empty fallback keeps
|
|
114
|
-
// callers that touch a possibly-unsegmented field (a `.map`/`.forEach`) safe.
|
|
115
|
-
function segmentsOf(ir: IR, field: Field): Segment[] {
|
|
116
|
-
return ir.analyses.segments[field] ?? [];
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// The first segment of a step field, narrowed to its step variant. The plan
|
|
120
|
-
// only routes step shapes here, whose (single) segment always classifies as a
|
|
121
|
-
// step; this asserts what the analysis guarantees but the type cannot express.
|
|
122
|
-
function stepSegment(ir: IR, field: Field): StepSegment {
|
|
123
|
-
return segmentsOf(ir, field)[0] as StepSegment;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// The sorted numeric values a field's segments cover, or null if any segment
|
|
127
|
-
// is not a discrete single (a range or sub-step is not a plain fire list).
|
|
128
|
-
function singleValues(segments: Segment[]): number[] | null {
|
|
129
|
-
const values: number[] = [];
|
|
130
|
-
|
|
131
|
-
for (const segment of segments) {
|
|
132
|
-
if (segment.kind !== 'single') {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
values.push(+segment.value);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return values;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Whether an hour stride wraps the day cleanly from within its first interval
|
|
143
|
-
// (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
|
|
144
|
-
// stride has no distinct endpoint and keeps its bare or "from M" cadence. Every
|
|
145
|
-
// other stride — a uneven interval, or one starting at or past its interval (a
|
|
146
|
-
// bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
|
|
147
|
-
function offsetCleanStride(
|
|
148
|
-
stride: {start: number; interval: number}
|
|
149
|
-
): boolean {
|
|
150
|
-
return stride.start < stride.interval && 24 % stride.interval === 0;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// An hour list's arithmetic progression, or null when its values are not a
|
|
154
|
-
// step the renderer should speak as a cadence. The core rewrites a uneven hour
|
|
155
|
-
// step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
|
|
156
|
-
// literal fire list, indistinguishable in the IR from a hand-written list; the
|
|
157
|
-
// renderer recovers the cadence from the values. A progression starting at
|
|
158
|
-
// zero is a `*/n` step however short (0,7,14,21 is `*/7`); a non-zero one is
|
|
159
|
-
// only a step when it is too long to be a deliberate clock-time list (e.g.
|
|
160
|
-
// 9,17 is two named times, not a cadence), the same length the minute/second
|
|
161
|
-
// list path uses. Interval one is a plain range, never a step.
|
|
162
|
-
function hourListStride(
|
|
163
|
-
values: number[]
|
|
164
|
-
): {start: number; interval: number; last: number} | null {
|
|
165
|
-
if (values.length < 2) {
|
|
166
|
-
return null;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const interval = values[1] - values[0];
|
|
170
|
-
|
|
171
|
-
if (interval < 2) {
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
for (let i = 2; i < values.length; i += 1) {
|
|
176
|
-
if (values[i] - values[i - 1] !== interval) {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (values[0] !== 0 && values.length < 5) {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return {interval, last: values[values.length - 1], start: values[0]};
|
|
186
|
-
}
|
|
187
29
|
|
|
188
|
-
export {
|
|
189
|
-
arithmeticStep, hourListStride, includes, isNonNegativeInteger,
|
|
190
|
-
offsetCleanStride, orderWeekdaysForDisplay, segmentsOf, singleValues,
|
|
191
|
-
stepSegment, toFieldNumber, unique
|
|
192
|
-
};
|
|
30
|
+
export {includes, isNonNegativeInteger, toFieldNumber, unique};
|
package/src/core/validate.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import {fieldOrder, fieldSpecs} from './specs.js';
|
|
5
5
|
import type {CronLike, FieldSpec} from './specs.js';
|
|
6
|
-
import type {Field} from './
|
|
6
|
+
import type {Field} from './schedule.js';
|
|
7
7
|
import {includes, isNonNegativeInteger, toFieldNumber} from './util.js';
|
|
8
8
|
|
|
9
9
|
// Validate every field of a cron-like object, throwing on the first
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Weekday display ordering: present weekdays Monday-first (Sunday last),
|
|
2
|
+
// independent of the Schedule's canonical Sunday=0 order. Display-only.
|
|
3
|
+
|
|
4
|
+
import type {Segment} from './schedule.js';
|
|
5
|
+
|
|
6
|
+
// The display sort key for a canonical weekday number: Monday (1) first,
|
|
7
|
+
// Sunday (0) last. The Schedule keeps Sunday=0 canonical; this is display-only.
|
|
8
|
+
function weekdayDisplayKey(value: number): number {
|
|
9
|
+
return value === 0 ? 7 : value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A weekday display segment: a single day or a (possibly wrap) range. Steps
|
|
13
|
+
// are flattened away into singles before sorting, so the result is only these
|
|
14
|
+
// two kinds; each renderer turns them into names exactly as it does today.
|
|
15
|
+
type WeekdaySegment =
|
|
16
|
+
| {kind: 'single'; value: string}
|
|
17
|
+
| {kind: 'range'; bounds: [string, string]};
|
|
18
|
+
|
|
19
|
+
// Reorder weekday segments Monday-first (Sunday last) for display, so a weekend
|
|
20
|
+
// list reads "Saturday and Sunday" rather than the canonical Sunday-first
|
|
21
|
+
// "Sunday and Saturday". Display-only: the Schedule / canonical order is
|
|
22
|
+
// unchanged (a fresh array is returned). A step expands to its fires as singles
|
|
23
|
+
// so the days sort into the list; a range stays one unit and keeps its own
|
|
24
|
+
// bounds order (a wrap range is not reordered into a list), sorting by its
|
|
25
|
+
// opening bound — so a lone range sorts to a one-element list and is unchanged.
|
|
26
|
+
// The sort is stable, so equal opening days keep input order.
|
|
27
|
+
function orderWeekdaysForDisplay(segments: Segment[]): WeekdaySegment[] {
|
|
28
|
+
const flattened: WeekdaySegment[] = segments.flatMap(function flat(segment) {
|
|
29
|
+
return segment.kind === 'step' ?
|
|
30
|
+
segment.fires.map(function single(value): WeekdaySegment {
|
|
31
|
+
return {kind: 'single', value: '' + value};
|
|
32
|
+
}) :
|
|
33
|
+
[segment];
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function key(segment: WeekdaySegment): number {
|
|
37
|
+
return segment.kind === 'range' ?
|
|
38
|
+
weekdayDisplayKey(+segment.bounds[0]) :
|
|
39
|
+
weekdayDisplayKey(+segment.value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return flattened
|
|
43
|
+
.map(function index(segment, position): [WeekdaySegment, number] {
|
|
44
|
+
return [segment, position];
|
|
45
|
+
})
|
|
46
|
+
.sort(function byDisplayKey(a, b): number {
|
|
47
|
+
return key(a[0]) - key(b[0]) || a[1] - b[1];
|
|
48
|
+
})
|
|
49
|
+
.map(function unwrap(pair): WeekdaySegment {
|
|
50
|
+
return pair[0];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export {orderWeekdaysForDisplay};
|