cronli5 0.2.0 → 0.3.1
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 +90 -0
- package/README.md +4 -4
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +514 -407
- package/dist/cronli5.js +514 -407
- package/dist/lang/de.cjs +296 -225
- package/dist/lang/de.js +296 -225
- package/dist/lang/en.cjs +471 -364
- package/dist/lang/en.js +471 -364
- package/dist/lang/es.cjs +318 -281
- package/dist/lang/es.js +318 -281
- package/dist/lang/fi.cjs +326 -276
- package/dist/lang/fi.js +326 -276
- package/dist/lang/zh.cjs +308 -236
- package/dist/lang/zh.js +308 -236
- package/package.json +1 -1
- package/src/core/analyze.ts +22 -21
- 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} +23 -24
- package/src/core/shapes.ts +8 -1
- package/src/core/specs.ts +1 -1
- package/src/core/util.ts +4 -83
- package/src/core/validate.ts +2 -2
- package/src/core/weekday.ts +54 -0
- package/src/cronli5.ts +7 -7
- package/src/lang/de/index.ts +329 -288
- package/src/lang/en/dialects.ts +1 -1
- package/src/lang/en/index.ts +640 -516
- package/src/lang/es/index.ts +342 -374
- package/src/lang/es/notes.md +1 -1
- package/src/lang/fi/dialects.ts +1 -1
- package/src/lang/fi/index.ts +367 -372
- package/src/lang/fi/notes.md +23 -8
- package/src/lang/fi/status.json +1 -1
- package/src/lang/zh/index.ts +344 -262
- package/src/types.ts +6 -6
- package/types/core/analyze.d.ts +4 -4
- 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} +16 -21
- package/types/core/shapes.d.ts +2 -1
- package/types/core/specs.d.ts +1 -1
- package/types/core/util.d.ts +1 -15
- 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/src/lang/en/index.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
// The English language module: renders an analyzed cron pattern (the
|
|
1
|
+
// The English language module: renders an analyzed cron pattern (the Schedule
|
|
2
2
|
// produced by core `analyze`) as idiomatic English. All words live here;
|
|
3
|
-
// the core stays semantic, and this module's only input is the
|
|
3
|
+
// the core stays semantic, and this module's only input is the Schedule.
|
|
4
4
|
// See docs/i18n-design.md.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
arithmeticStep, hourListStride, offsetCleanStride,
|
|
8
|
+
renderStride as chooseStride, segmentsOf, singleValues, stepSegment
|
|
9
|
+
} from '../../core/cadence.js';
|
|
10
|
+
import {orderWeekdaysForDisplay} from '../../core/weekday.js';
|
|
11
|
+
import {isOpenStep} from '../../core/shapes.js';
|
|
7
12
|
import {maxClockTimes} from '../../core/specs.js';
|
|
8
13
|
import {clockDigits, numeral, pad} from '../../core/format.js';
|
|
9
14
|
import type {Cronli5Options} from '../../types.js';
|
|
10
15
|
import type {
|
|
11
|
-
HourTimesPlan,
|
|
12
|
-
} from '../../core/
|
|
16
|
+
HourTimesPlan, Schedule, Language, NormalizedOptions, PlanNode, Segment
|
|
17
|
+
} from '../../core/schedule.js';
|
|
13
18
|
import {resolveDialect} from './dialects.js';
|
|
14
19
|
|
|
15
20
|
// The plan node of a given kind: the discriminated-union member a renderer
|
|
@@ -33,6 +38,17 @@ interface Stride {
|
|
|
33
38
|
anchor: string;
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
// A contiguous hour range to phrase as a window. `from`/`to` are the bounding
|
|
42
|
+
// hours; `throughMinute` is the close minute used by the "through" span;
|
|
43
|
+
// `continuous` is true only when the run fills every minute of the final hour
|
|
44
|
+
// (a wildcard minute), which earns the default dialect's until-window.
|
|
45
|
+
interface HourWindowSpec {
|
|
46
|
+
from: number;
|
|
47
|
+
to: number;
|
|
48
|
+
throughMinute: number | string;
|
|
49
|
+
continuous: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
// A clock-time entry assembled for rendering. Hour/minute/second arrive as
|
|
37
53
|
// numbers or as raw field tokens (a range bound or single value is a
|
|
38
54
|
// string); `plain` suppresses the noon/midnight words. `explicit` forces the
|
|
@@ -128,96 +144,224 @@ function normalizeOptions(options?: Cronli5Options): NormalizedOptions {
|
|
|
128
144
|
};
|
|
129
145
|
}
|
|
130
146
|
|
|
131
|
-
// Render an analyzed cron pattern (the
|
|
132
|
-
function describe(
|
|
147
|
+
// Render an analyzed cron pattern (the Schedule) as English.
|
|
148
|
+
function describe(schedule: Schedule, opts: NormalizedOptions): string {
|
|
149
|
+
// A dense pattern — a seconds cadence stacked on a minutes cadence under an
|
|
150
|
+
// hours cadence — reads coarse-to-fine with the second nested under the
|
|
151
|
+
// minute, leading with the calendar anchor; it preempts the fine-to-coarse
|
|
152
|
+
// run-on the per-plan composer would otherwise produce.
|
|
153
|
+
const dense = denseCadence(schedule, opts);
|
|
154
|
+
|
|
155
|
+
if (dense !== null) {
|
|
156
|
+
return applyYear(dense, schedule, opts);
|
|
157
|
+
}
|
|
158
|
+
|
|
133
159
|
// A finer leading cadence puts each coarser field in the confinement frame,
|
|
134
160
|
// overriding the per-plan juxtaposed-cadence and duration-frame forms.
|
|
135
|
-
const body = confinement(
|
|
161
|
+
const body = confinement(schedule, opts) ??
|
|
162
|
+
render(schedule, schedule.plan, opts);
|
|
136
163
|
|
|
137
164
|
// A day union scopes the whole clause by its month, which leads the
|
|
138
165
|
// description ("in June <time> whenever the day is …"); the time/cadence and
|
|
139
166
|
// the trailing condition are already in `body`.
|
|
140
|
-
const lead = isDayUnion(
|
|
167
|
+
const lead = isDayUnion(schedule, opts) ?
|
|
168
|
+
dayUnionMonthLead(schedule, opts) : '';
|
|
141
169
|
|
|
142
|
-
return applyYear(lead + body,
|
|
170
|
+
return applyYear(lead + body, schedule, opts);
|
|
143
171
|
}
|
|
144
172
|
|
|
145
173
|
// Render one plan node. `composeSeconds` recurses with its `rest` plan.
|
|
146
|
-
function render(
|
|
174
|
+
function render(schedule: Schedule, plan: PlanNode,
|
|
175
|
+
opts: NormalizedOptions): string {
|
|
147
176
|
// The dispatch table keys each renderer to its own plan kind; the lookup
|
|
148
177
|
// by `plan.kind` cannot prove the node matches the renderer's narrowed
|
|
149
178
|
// parameter, so the call is made through a kind-agnostic signature.
|
|
150
179
|
const renderer = renderers[plan.kind] as
|
|
151
|
-
(
|
|
180
|
+
(schedule: Schedule, plan: PlanNode, opts: NormalizedOptions) => string;
|
|
181
|
+
|
|
182
|
+
return renderer(schedule, plan, opts);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- Dense multi-cadence restructure. ---
|
|
186
|
+
|
|
187
|
+
// Whether a field's shape is a true cadence — a repeating pattern (step, range,
|
|
188
|
+
// or enumerated list), not a wildcard or a single pinned value. A dense pattern
|
|
189
|
+
// stacks one of these in the second, the minute, and the hour.
|
|
190
|
+
function isCadenceShape(shape: Schedule['shapes'][keyof Schedule['shapes']]):
|
|
191
|
+
boolean {
|
|
192
|
+
return shape === 'step' || shape === 'range' || shape === 'list';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A dense pattern is a seconds cadence stacked on a minutes cadence under an
|
|
196
|
+
// hours cadence: three independent cadences whose flat fine-to-coarse run-on
|
|
197
|
+
// reads as a robotic list. It is recognized only on the `composeSeconds` plan
|
|
198
|
+
// (a meaningful second over a coarser rest), with all three of second, minute,
|
|
199
|
+
// and hour a cadence, and no day union (which owns its own leading-month
|
|
200
|
+
// structure). The hour may take any cadence shape — a stride, a range window,
|
|
201
|
+
// or a list/range-with-outlier — each rendered in its own existing leaf form
|
|
202
|
+
// inside the restructured frame. A `clockTimes` rest is excluded: there the
|
|
203
|
+
// minute and hour fold into a named clock-time enumeration ("every 15 seconds
|
|
204
|
+
// of 9:00 a.m., 9:25 a.m., …"), a compact form already better than a run-on, so
|
|
205
|
+
// it is left as is. Restricted to the default dialect's voice — the same scope
|
|
206
|
+
// as the confinement frame — so other dialects and the compact `short` form
|
|
207
|
+
// keep their established phrasing.
|
|
208
|
+
function isDenseCadence(schedule: Schedule, opts: NormalizedOptions): boolean {
|
|
209
|
+
if (!opts.style.untilWindow || opts.short ||
|
|
210
|
+
schedule.plan.kind !== 'composeSeconds' ||
|
|
211
|
+
schedule.plan.rest.kind === 'clockTimes' ||
|
|
212
|
+
isDayUnion(schedule, opts)) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
152
215
|
|
|
153
|
-
|
|
154
|
-
}
|
|
216
|
+
const {shapes} = schedule;
|
|
155
217
|
|
|
156
|
-
|
|
218
|
+
return isCadenceShape(shapes.second) && isCadenceShape(shapes.minute) &&
|
|
219
|
+
isCadenceShape(shapes.hour);
|
|
220
|
+
}
|
|
157
221
|
|
|
158
|
-
|
|
222
|
+
// The coarse hour cadence as a standalone fragment: a stride reads as its
|
|
223
|
+
// bounded/bare cadence ("every five hours from midnight through 8 p.m.", "every
|
|
224
|
+
// six hours"); a plain range reads as its window ("from 8 a.m. through 6
|
|
225
|
+
// p.m."), the non-continuous form a stepped minute uses inside the range; a
|
|
226
|
+
// list or range-with-outlier reads as its "during the … hours" frame (the same
|
|
227
|
+
// phrasing the confinement form produces, just hoisted into the dense lead).
|
|
228
|
+
function denseHourFragment(schedule: Schedule,
|
|
159
229
|
opts: NormalizedOptions): string {
|
|
160
|
-
|
|
230
|
+
const stride = hourStride(schedule);
|
|
231
|
+
|
|
232
|
+
if (stride) {
|
|
233
|
+
return hourStrideCadence(stride, opts);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (schedule.shapes.hour === 'range') {
|
|
237
|
+
// A plain range hour, whose single range segment carries the window bounds.
|
|
238
|
+
const segment = segmentsOf(schedule, 'hour').find(function range(part) {
|
|
239
|
+
return part.kind === 'range';
|
|
240
|
+
}) as Extract<Segment, {kind: 'range'}>;
|
|
241
|
+
|
|
242
|
+
return rangeWindow({
|
|
243
|
+
continuous: false,
|
|
244
|
+
from: +segment.bounds[0],
|
|
245
|
+
throughMinute: 0,
|
|
246
|
+
to: +segment.bounds[1]
|
|
247
|
+
}, opts);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A list or range-with-outlier hour ("9-20,22") reads as the discrete
|
|
251
|
+
// "during the <times> hours" frame, the same construction the hour
|
|
252
|
+
// confinement uses for these shapes.
|
|
253
|
+
return 'during the ' +
|
|
254
|
+
hourSegmentTimes(schedule, {minute: 0, second: null}, false, opts) +
|
|
255
|
+
' hours';
|
|
161
256
|
}
|
|
162
257
|
|
|
163
|
-
|
|
258
|
+
// The minute cadence as a standalone fragment, counted past the hour: a step is
|
|
259
|
+
// its stride phrase, a range its "every minute from M through K" lead, and a
|
|
260
|
+
// list its stride-or-enumeration.
|
|
261
|
+
function denseMinuteFragment(schedule: Schedule,
|
|
164
262
|
opts: NormalizedOptions): string {
|
|
165
|
-
|
|
263
|
+
if (schedule.shapes.minute === 'step') {
|
|
264
|
+
return stepCycle60(stepSegment(schedule, 'minute'), 'minute', 'hour', opts);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (schedule.shapes.minute === 'range') {
|
|
268
|
+
return minuteRangeLead(schedule.pattern.minute, opts);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// A minute list has segments; an offset/uneven step the core enumerated to a
|
|
272
|
+
// list reads as a stride when its fires form a progression.
|
|
273
|
+
return strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
|
|
274
|
+
opts) ?? listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
|
|
275
|
+
'minute', 'hour', opts);
|
|
166
276
|
}
|
|
167
277
|
|
|
168
|
-
|
|
278
|
+
// Assemble the dense form, or null when the pattern is not dense. The calendar
|
|
279
|
+
// anchor leads ("on the last weekday of the month, …"), the cadences run
|
|
280
|
+
// coarse-to-fine (hour, then minute), and the second nests under the minute
|
|
281
|
+
// ("…, and within each of those minutes, every second …"). Each fragment is
|
|
282
|
+
// today's leaf phrasing, reordered and nested but otherwise unchanged.
|
|
283
|
+
function denseCadence(schedule: Schedule,
|
|
284
|
+
opts: NormalizedOptions): string | null {
|
|
285
|
+
if (!isDenseCadence(schedule, opts)) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const hour = denseHourFragment(schedule, opts);
|
|
290
|
+
const minute = denseMinuteFragment(schedule, opts);
|
|
291
|
+
const second = secondsClause(schedule, 'minute', opts);
|
|
292
|
+
const nested = hour + ', ' + minute +
|
|
293
|
+
', and within each of those minutes, ' + second;
|
|
294
|
+
|
|
295
|
+
// A trailing day qualifier (" on the last weekday of the month") leads the
|
|
296
|
+
// dense form instead; with no anchor the hour cadence leads alone.
|
|
297
|
+
const anchor = trailingQualifier(schedule, opts).trim();
|
|
298
|
+
|
|
299
|
+
return anchor ? anchor + ', ' + nested : nested;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// --- Seconds renderers. ---
|
|
303
|
+
|
|
304
|
+
function renderEverySecond(schedule: Schedule, plan: PlanOf<'everySecond'>,
|
|
169
305
|
opts: NormalizedOptions): string {
|
|
170
|
-
|
|
306
|
+
return 'every second' + trailingQualifier(schedule, opts);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function renderStandaloneSeconds(schedule: Schedule,
|
|
310
|
+
plan: PlanOf<'standaloneSeconds'>, opts: NormalizedOptions): string {
|
|
311
|
+
return secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function renderSecondPastMinute(schedule: Schedule,
|
|
315
|
+
plan: PlanOf<'secondPastMinute'>, opts: NormalizedOptions): string {
|
|
316
|
+
const secondField = schedule.pattern.second;
|
|
171
317
|
|
|
172
318
|
return getNumber(secondField, opts) + ' ' +
|
|
173
319
|
pluralize(secondField, 'second') +
|
|
174
|
-
' past the minute, every minute' + trailingQualifier(
|
|
320
|
+
' past the minute, every minute' + trailingQualifier(schedule, opts);
|
|
175
321
|
}
|
|
176
322
|
|
|
177
323
|
// A meaningful second combined with a single specific minute (and an open
|
|
178
324
|
// hour). A single second folds into the minute anchor ("30 minutes and 15
|
|
179
325
|
// seconds past the hour, every hour"); a list, range, or step leads with
|
|
180
326
|
// its own clause.
|
|
181
|
-
function renderSecondsWithinMinute(
|
|
182
|
-
opts: NormalizedOptions): string {
|
|
183
|
-
const minuteField =
|
|
327
|
+
function renderSecondsWithinMinute(schedule: Schedule,
|
|
328
|
+
plan: PlanOf<'secondsWithinMinute'>, opts: NormalizedOptions): string {
|
|
329
|
+
const minuteField = schedule.pattern.minute;
|
|
184
330
|
const minuteWord = getNumber(minuteField, opts);
|
|
185
331
|
const minuteUnit = pluralize(minuteField, 'minute');
|
|
186
332
|
|
|
187
333
|
if (plan.singleSecond) {
|
|
188
|
-
const secondField =
|
|
334
|
+
const secondField = schedule.pattern.second;
|
|
189
335
|
|
|
190
336
|
return minuteWord + ' ' + minuteUnit + ' and ' +
|
|
191
337
|
getNumber(secondField, opts) + ' ' + pluralize(secondField, 'second') +
|
|
192
|
-
' past the hour, every hour' + trailingQualifier(
|
|
338
|
+
' past the hour, every hour' + trailingQualifier(schedule, opts);
|
|
193
339
|
}
|
|
194
340
|
|
|
195
|
-
return secondsLeadClause(
|
|
341
|
+
return secondsLeadClause(schedule, opts) + ', ' + minuteWord + ' ' +
|
|
196
342
|
minuteUnit + ' past the hour, every hour' +
|
|
197
|
-
trailingQualifier(
|
|
343
|
+
trailingQualifier(schedule, opts);
|
|
198
344
|
}
|
|
199
345
|
|
|
200
346
|
// The hour-cadence rendering of a compose-seconds plan whose clock-time rest
|
|
201
347
|
// would cross-multiply an hour stride under a single pinned minute, or null
|
|
202
348
|
// when that does not apply (a non-clock rest, a multi-valued minute, or an
|
|
203
349
|
// hour that is not a stride).
|
|
204
|
-
function composeHourCadence(
|
|
350
|
+
function composeHourCadence(schedule: Schedule, plan: PlanOf<'composeSeconds'>,
|
|
205
351
|
opts: NormalizedOptions): string | null {
|
|
206
352
|
const clockRest = plan.rest.kind === 'clockTimes' ||
|
|
207
353
|
plan.rest.kind === 'compactClockTimes';
|
|
208
354
|
|
|
209
|
-
if (!clockRest ||
|
|
355
|
+
if (!clockRest || schedule.shapes.minute !== 'single') {
|
|
210
356
|
return null;
|
|
211
357
|
}
|
|
212
358
|
|
|
213
|
-
const minute = +
|
|
359
|
+
const minute = +schedule.pattern.minute;
|
|
214
360
|
|
|
215
|
-
return hourCadence(
|
|
361
|
+
return hourCadence(schedule, minute, opts) ??
|
|
362
|
+
hourRangeCadence(schedule, minute, opts);
|
|
216
363
|
}
|
|
217
364
|
|
|
218
|
-
// A meaningful second under minute/hour shapes the earlier strategies
|
|
219
|
-
// deferred on: the second leads with its own clause and the rest of the
|
|
220
|
-
// pattern follows.
|
|
221
365
|
// A wildcard or stepped second under a fixed minute across one or more specific
|
|
222
366
|
// hours. The clock-time rest collapses the pinned minute into the hour, and on
|
|
223
367
|
// the clock a pinned minute-0 reads as the whole hour ("9 a.m." spoken ==
|
|
@@ -231,23 +375,24 @@ function composeHourCadence(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
|
231
375
|
// form, never collapsing to the bare hour (which once repeated it, "9 a.m.,
|
|
232
376
|
// 9 a.m."). A non-zero pinned minute is an unambiguous clock time the compact
|
|
233
377
|
// "of 9:05 a.m." form reads as the minute, never the hour.
|
|
234
|
-
function clockTimesConfinement(
|
|
235
|
-
opts: NormalizedOptions): string {
|
|
236
|
-
if (+rest.times[0].minute === 0 &&
|
|
237
|
-
return secondsLeadClause(
|
|
238
|
-
durationHours(
|
|
378
|
+
function clockTimesConfinement(schedule: Schedule,
|
|
379
|
+
rest: PlanOf<'clockTimes'>, opts: NormalizedOptions): string {
|
|
380
|
+
if (+rest.times[0].minute === 0 && schedule.shapes.minute === 'single') {
|
|
381
|
+
return secondsLeadClause(schedule, opts) + ' for one minute at ' +
|
|
382
|
+
durationHours(schedule, rest, opts);
|
|
239
383
|
}
|
|
240
384
|
|
|
241
|
-
return secondsLeadClause(
|
|
385
|
+
return secondsLeadClause(schedule, opts) + ' of ' +
|
|
386
|
+
clockTimesOf(schedule, rest, opts);
|
|
242
387
|
}
|
|
243
388
|
|
|
244
|
-
function renderComposeSeconds(
|
|
245
|
-
opts: NormalizedOptions): string {
|
|
389
|
+
function renderComposeSeconds(schedule: Schedule,
|
|
390
|
+
plan: PlanOf<'composeSeconds'>, opts: NormalizedOptions): string {
|
|
246
391
|
// An hour step (or arithmetic-progression hour list) under a single pinned
|
|
247
392
|
// minute is a cadence, not a wall of clock times: speak the second/minute
|
|
248
393
|
// lead, then the hour cadence ("at 30 seconds past the hour, every two
|
|
249
394
|
// hours"). The clock-time rest would otherwise cross-multiply the hours.
|
|
250
|
-
const cadence = composeHourCadence(
|
|
395
|
+
const cadence = composeHourCadence(schedule, plan, opts);
|
|
251
396
|
|
|
252
397
|
if (cadence !== null) {
|
|
253
398
|
return cadence;
|
|
@@ -256,8 +401,9 @@ function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
|
256
401
|
// A wildcard or stepped second under a fixed minute across one or more
|
|
257
402
|
// specific hours confines the seconds to the clock time(s).
|
|
258
403
|
if (plan.rest.kind === 'clockTimes' &&
|
|
259
|
-
(
|
|
260
|
-
|
|
404
|
+
(schedule.shapes.second === 'wildcard' ||
|
|
405
|
+
schedule.shapes.second === 'step')) {
|
|
406
|
+
return clockTimesConfinement(schedule, plan.rest, opts);
|
|
261
407
|
}
|
|
262
408
|
|
|
263
409
|
// A wildcard second under a */2 minute step with a wildcard hour binds
|
|
@@ -265,12 +411,12 @@ function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
|
265
411
|
// the natural English for an interval of 2, and "of" joins the two without
|
|
266
412
|
// the ambiguity of a comma, which reads as two independent cadences.
|
|
267
413
|
// Scoped to */2 only; other step sizes keep the comma form.
|
|
268
|
-
if (
|
|
414
|
+
if (schedule.shapes.second === 'wildcard' &&
|
|
269
415
|
plan.rest.kind === 'minuteFrequency' &&
|
|
270
416
|
plan.rest.hours.kind === 'none' &&
|
|
271
|
-
|
|
417
|
+
schedule.pattern.minute === '*/2') {
|
|
272
418
|
return 'every second of every other minute' +
|
|
273
|
-
trailingQualifier(
|
|
419
|
+
trailingQualifier(schedule, opts);
|
|
274
420
|
}
|
|
275
421
|
|
|
276
422
|
// A compact clock-time rest folds a meaningful SINGLE second into its own
|
|
@@ -278,22 +424,22 @@ function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
|
278
424
|
// double it. A wildcard or stepped second is not folded there (no
|
|
279
425
|
// clockSecond), so it still leads its own clause here.
|
|
280
426
|
const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
|
|
281
|
-
|
|
282
|
-
const lead = restOwnsLead ? '' : secondsLeadClause(
|
|
427
|
+
schedule.analyses.clockSecond;
|
|
428
|
+
const lead = restOwnsLead ? '' : secondsLeadClause(schedule, opts) + ', ';
|
|
283
429
|
|
|
284
|
-
return lead + render(
|
|
430
|
+
return lead + render(schedule, plan.rest, opts);
|
|
285
431
|
}
|
|
286
432
|
|
|
287
433
|
// The bare-hour words for a minute-0 duration confinement, joined and followed
|
|
288
434
|
// by the trailing day qualifier: "9 a.m. and 11 a.m., every day", "midnight,
|
|
289
435
|
// 2 a.m., …, every day". The hour reads as its word (noon/midnight included),
|
|
290
436
|
// never "H:00", since the "for one minute" frame already carries the minute.
|
|
291
|
-
function durationHours(
|
|
437
|
+
function durationHours(schedule: Schedule, plan: PlanOf<'clockTimes'>,
|
|
292
438
|
opts: NormalizedOptions): string {
|
|
293
439
|
const hours = plan.times.map(function clock(time) {
|
|
294
440
|
return getTime({hour: time.hour, minute: 0}, opts);
|
|
295
441
|
});
|
|
296
|
-
const trail = dayQualifier(
|
|
442
|
+
const trail = dayQualifier(schedule, leadingWords, opts);
|
|
297
443
|
|
|
298
444
|
return joinList(hours, opts) + (trail && ', ' + trail);
|
|
299
445
|
}
|
|
@@ -301,7 +447,7 @@ function durationHours(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
|
301
447
|
// The clock times for a non-zero pinned-minute compose-seconds rest, joined
|
|
302
448
|
// and followed by the trailing day qualifier: "9:05 a.m. and 11:05 a.m.,
|
|
303
449
|
// every day". The non-zero minute reads as a clock time, never the hour.
|
|
304
|
-
function clockTimesOf(
|
|
450
|
+
function clockTimesOf(schedule: Schedule, plan: PlanOf<'clockTimes'>,
|
|
305
451
|
opts: NormalizedOptions): string {
|
|
306
452
|
const times = plan.times.map(function clock(time) {
|
|
307
453
|
return getTime({
|
|
@@ -311,7 +457,7 @@ function clockTimesOf(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
|
311
457
|
explicit: true
|
|
312
458
|
}, opts);
|
|
313
459
|
});
|
|
314
|
-
const trail = dayQualifier(
|
|
460
|
+
const trail = dayQualifier(schedule, leadingWords, opts);
|
|
315
461
|
|
|
316
462
|
return joinList(times, opts) + (trail && ', ' + trail);
|
|
317
463
|
}
|
|
@@ -319,8 +465,9 @@ function clockTimesOf(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
|
319
465
|
// The leading clause describing a second field relative to the minute,
|
|
320
466
|
// e.g. "at 5 and 10 seconds past the minute" or "every second from zero
|
|
321
467
|
// through 30 past the minute".
|
|
322
|
-
function secondsLeadClause(
|
|
323
|
-
|
|
468
|
+
function secondsLeadClause(schedule: Schedule,
|
|
469
|
+
opts: NormalizedOptions): string {
|
|
470
|
+
return secondsClause(schedule, 'minute', opts);
|
|
324
471
|
}
|
|
325
472
|
|
|
326
473
|
// The second clause counted against an arbitrary anchor. The anchor is
|
|
@@ -328,10 +475,10 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
|
|
|
328
475
|
// pinned minute 0 into the hour and counts the second "past the hour"
|
|
329
476
|
// instead ("at 30 seconds past the hour", "every second from 0 through 10
|
|
330
477
|
// past the hour"), so the minute-0 confinement is stated, not dropped.
|
|
331
|
-
function secondsClause(
|
|
478
|
+
function secondsClause(schedule: Schedule, anchor: string,
|
|
332
479
|
opts: NormalizedOptions): string {
|
|
333
|
-
const secondField =
|
|
334
|
-
const shape =
|
|
480
|
+
const secondField = schedule.pattern.second;
|
|
481
|
+
const shape = schedule.shapes.second;
|
|
335
482
|
|
|
336
483
|
if (secondField === '*') {
|
|
337
484
|
return 'every second';
|
|
@@ -340,7 +487,7 @@ function secondsClause(ir: IR, anchor: string,
|
|
|
340
487
|
if (shape === 'step') {
|
|
341
488
|
// The plan reached this clause only for a stepped second field, whose
|
|
342
489
|
// first segment is always a step segment.
|
|
343
|
-
return stepCycle60(
|
|
490
|
+
return stepCycle60(stepSegment(schedule, 'second'),
|
|
344
491
|
'second', anchor, opts);
|
|
345
492
|
}
|
|
346
493
|
|
|
@@ -360,76 +507,85 @@ function secondsClause(ir: IR, anchor: string,
|
|
|
360
507
|
// A non-wildcard second under the list/step path always has segments. An
|
|
361
508
|
// offset/uneven step the core enumerated to a fire list reads as a stride
|
|
362
509
|
// cadence when those fires form a long-enough progression.
|
|
363
|
-
return strideFromSegments(
|
|
364
|
-
opts) ?? listPastThe(segmentWords(
|
|
510
|
+
return strideFromSegments(segmentsOf(schedule, 'second'), 'second', anchor,
|
|
511
|
+
opts) ?? listPastThe(segmentWords(segmentsOf(schedule, 'second'), opts),
|
|
365
512
|
'second', anchor, opts);
|
|
366
513
|
}
|
|
367
514
|
|
|
368
515
|
// --- Minute renderers. ---
|
|
369
516
|
|
|
370
|
-
function renderEveryMinute(
|
|
517
|
+
function renderEveryMinute(schedule: Schedule, plan: PlanOf<'everyMinute'>,
|
|
371
518
|
opts: NormalizedOptions): string {
|
|
372
|
-
return 'every minute' + trailingQualifier(
|
|
519
|
+
return 'every minute' + trailingQualifier(schedule, opts);
|
|
373
520
|
}
|
|
374
521
|
|
|
375
|
-
function renderSingleMinute(
|
|
522
|
+
function renderSingleMinute(schedule: Schedule, plan: PlanOf<'singleMinute'>,
|
|
376
523
|
opts: NormalizedOptions): string {
|
|
377
|
-
const minuteField =
|
|
524
|
+
const minuteField = schedule.pattern.minute;
|
|
378
525
|
|
|
379
526
|
return getNumber(minuteField, opts) + ' ' +
|
|
380
527
|
pluralize(minuteField, 'minute') +
|
|
381
|
-
' past the hour, every hour' + trailingQualifier(
|
|
528
|
+
' past the hour, every hour' + trailingQualifier(schedule, opts);
|
|
382
529
|
}
|
|
383
530
|
|
|
384
|
-
function renderRangeOfMinutes(
|
|
385
|
-
opts: NormalizedOptions): string {
|
|
386
|
-
return minuteRangeLead(
|
|
387
|
-
trailingQualifier(
|
|
531
|
+
function renderRangeOfMinutes(schedule: Schedule,
|
|
532
|
+
plan: PlanOf<'rangeOfMinutes'>, opts: NormalizedOptions): string {
|
|
533
|
+
return minuteRangeLead(schedule.pattern.minute, opts) +
|
|
534
|
+
trailingQualifier(schedule, opts);
|
|
388
535
|
}
|
|
389
536
|
|
|
390
|
-
function renderMultipleMinutes(
|
|
391
|
-
opts: NormalizedOptions): string {
|
|
537
|
+
function renderMultipleMinutes(schedule: Schedule,
|
|
538
|
+
plan: PlanOf<'multipleMinutes'>, opts: NormalizedOptions): string {
|
|
392
539
|
// A multiple-minutes plan is selected only for a minute list, which has
|
|
393
540
|
// segments. An offset/uneven step the core enumerated to this list reads as
|
|
394
541
|
// a stride cadence when the fires form a long-enough progression.
|
|
395
542
|
const stride =
|
|
396
|
-
strideFromSegments(
|
|
543
|
+
strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour', opts);
|
|
397
544
|
|
|
398
|
-
return (stride ?? listPastThe(segmentWords(
|
|
399
|
-
opts), 'minute', 'hour', opts)) + trailingQualifier(
|
|
545
|
+
return (stride ?? listPastThe(segmentWords(segmentsOf(schedule, 'minute'),
|
|
546
|
+
opts), 'minute', 'hour', opts)) + trailingQualifier(schedule, opts);
|
|
400
547
|
}
|
|
401
548
|
|
|
402
549
|
// A repeating minute step, qualified by the active hour window(s).
|
|
403
|
-
function renderMinuteFrequency(
|
|
404
|
-
opts: NormalizedOptions): string {
|
|
550
|
+
function renderMinuteFrequency(schedule: Schedule,
|
|
551
|
+
plan: PlanOf<'minuteFrequency'>, opts: NormalizedOptions): string {
|
|
405
552
|
// A minute-frequency plan is selected only for a stepped minute field,
|
|
406
553
|
// which has segments.
|
|
407
|
-
let phrase = stepCycle60(
|
|
554
|
+
let phrase = stepCycle60(stepSegment(schedule, 'minute'),
|
|
408
555
|
'minute', 'hour', opts);
|
|
409
556
|
|
|
410
557
|
if (plan.hours.kind === 'during') {
|
|
411
558
|
// A uneven hour stride confines the minute cadence to its own bounded hour
|
|
412
559
|
// cadence ("every 15 minutes, every five hours from midnight through 8
|
|
413
560
|
// p.m."); an irregular hour list still names each hour's window.
|
|
414
|
-
const cadence = unevenHourCadence(
|
|
561
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
415
562
|
|
|
416
563
|
phrase += cadence ?
|
|
417
564
|
', ' + cadence :
|
|
418
565
|
' during the ' +
|
|
419
|
-
hourTimesFromPlan(
|
|
566
|
+
hourTimesFromPlan(schedule, plan.hours.times, false, opts) + ' hours';
|
|
420
567
|
}
|
|
421
568
|
else if (plan.hours.kind === 'window') {
|
|
422
|
-
|
|
569
|
+
// A minute-frequency cadence ("every 15 minutes") fills the hours from a
|
|
570
|
+
// STEPPED minute, never a wildcard one, so its run is not continuous to the
|
|
571
|
+
// top of the next hour: the default dialect reads "through <last hour>" and
|
|
572
|
+
// every other dialect closes on the step's last fire (`last`).
|
|
573
|
+
phrase += ' ' + rangeWindow({
|
|
574
|
+
continuous: false,
|
|
575
|
+
from: plan.hours.from,
|
|
576
|
+
throughMinute: plan.hours.last,
|
|
577
|
+
to: plan.hours.to
|
|
578
|
+
}, opts);
|
|
423
579
|
}
|
|
424
580
|
else if (plan.hours.kind === 'step') {
|
|
425
581
|
// The plan carries a step only for a clean stride (dividing the day),
|
|
426
582
|
// which confines the cadence to every Nth hour; a stepped hour field's
|
|
427
583
|
// first segment is a step segment.
|
|
428
584
|
phrase += ' ' +
|
|
429
|
-
everyNthHour(
|
|
585
|
+
everyNthHour(stepSegment(schedule, 'hour'), opts);
|
|
430
586
|
}
|
|
431
587
|
|
|
432
|
-
return phrase + trailingQualifier(
|
|
588
|
+
return phrase + trailingQualifier(schedule, opts);
|
|
433
589
|
}
|
|
434
590
|
|
|
435
591
|
// A minute wildcard or plain range under a single specific hour fires
|
|
@@ -437,43 +593,43 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
|
|
|
437
593
|
// whole hour, so it reads as that hour itself ("every minute of the 9 a.m.
|
|
438
594
|
// hour") rather than a synthesized "from H:00 through H:59" range the source
|
|
439
595
|
// never stated; a plain range is a real window and keeps "from … through …".
|
|
440
|
-
function renderMinuteSpanInHour(
|
|
441
|
-
opts: NormalizedOptions): string {
|
|
442
|
-
if (
|
|
596
|
+
function renderMinuteSpanInHour(schedule: Schedule,
|
|
597
|
+
plan: PlanOf<'minuteSpanInHour'>, opts: NormalizedOptions): string {
|
|
598
|
+
if (schedule.pattern.minute === '*') {
|
|
443
599
|
return 'every minute of the ' +
|
|
444
600
|
getTime({hour: plan.hour, minute: 0}, opts) + ' hour' +
|
|
445
|
-
trailingQualifier(
|
|
601
|
+
trailingQualifier(schedule, opts);
|
|
446
602
|
}
|
|
447
603
|
|
|
448
604
|
return 'every minute from ' +
|
|
449
605
|
getTime({hour: plan.hour, minute: plan.span[0]}, opts) +
|
|
450
606
|
through(opts) + getTime({hour: plan.hour, minute: plan.span[1]}, opts) +
|
|
451
|
-
trailingQualifier(
|
|
607
|
+
trailingQualifier(schedule, opts);
|
|
452
608
|
}
|
|
453
609
|
|
|
454
610
|
// A minute window combined with discrete hours fires within that window
|
|
455
611
|
// during each hour.
|
|
456
|
-
function renderMinutesAcrossHours(
|
|
457
|
-
opts: NormalizedOptions): string {
|
|
612
|
+
function renderMinutesAcrossHours(schedule: Schedule,
|
|
613
|
+
plan: PlanOf<'minutesAcrossHours'>, opts: NormalizedOptions): string {
|
|
458
614
|
// A uneven hour stride reads as a cadence, not a wall of hour columns: the
|
|
459
615
|
// minute lead, then "every N hours from X through Y".
|
|
460
|
-
const cadence = unevenHourCadence(
|
|
616
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
461
617
|
|
|
462
618
|
if (plan.form === 'wildcard') {
|
|
463
619
|
if (cadence !== null) {
|
|
464
|
-
return 'every minute, ' + cadence + trailingQualifier(
|
|
620
|
+
return 'every minute, ' + cadence + trailingQualifier(schedule, opts);
|
|
465
621
|
}
|
|
466
622
|
|
|
467
623
|
return 'every minute during the ' +
|
|
468
|
-
hourTimesFromPlan(
|
|
469
|
-
trailingQualifier(
|
|
624
|
+
hourTimesFromPlan(schedule, plan.times, false, opts) + ' hours' +
|
|
625
|
+
trailingQualifier(schedule, opts);
|
|
470
626
|
}
|
|
471
627
|
|
|
472
628
|
if (plan.form === 'range') {
|
|
473
|
-
const lead = minuteRangeLead(
|
|
629
|
+
const lead = minuteRangeLead(schedule.pattern.minute, opts);
|
|
474
630
|
|
|
475
631
|
if (cadence !== null) {
|
|
476
|
-
return lead + ', ' + cadence + trailingQualifier(
|
|
632
|
+
return lead + ', ' + cadence + trailingQualifier(schedule, opts);
|
|
477
633
|
}
|
|
478
634
|
|
|
479
635
|
// A plain minute range is a cadence, so an hour list confines it with the
|
|
@@ -484,30 +640,31 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
|
|
|
484
640
|
// hour, at 9 a.m."), never the plural "hours" confinement.
|
|
485
641
|
if (singleHourFire(plan.times)) {
|
|
486
642
|
return lead + ', at ' +
|
|
487
|
-
hourTimesFromPlan(
|
|
488
|
-
trailingQualifier(
|
|
643
|
+
hourTimesFromPlan(schedule, plan.times, true, opts) +
|
|
644
|
+
trailingQualifier(schedule, opts);
|
|
489
645
|
}
|
|
490
646
|
|
|
491
647
|
return lead + ' during the ' +
|
|
492
|
-
hourTimesFromPlan(
|
|
493
|
-
trailingQualifier(
|
|
648
|
+
hourTimesFromPlan(schedule, plan.times, false, opts) + ' hours' +
|
|
649
|
+
trailingQualifier(schedule, opts);
|
|
494
650
|
}
|
|
495
651
|
|
|
496
652
|
// The 'list' form is a minute list, which has segments; an offset/uneven
|
|
497
653
|
// step enumerated to that list reads as a stride. A list is a set of
|
|
498
654
|
// discrete fire minutes, not a cadence, so it keeps the "at <times>" frame.
|
|
499
655
|
const lead =
|
|
500
|
-
strideFromSegments(
|
|
501
|
-
|
|
656
|
+
strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
|
|
657
|
+
opts) ??
|
|
658
|
+
listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
|
|
502
659
|
'minute', 'hour', opts);
|
|
503
660
|
|
|
504
661
|
if (cadence !== null) {
|
|
505
|
-
return lead + ', ' + cadence + trailingQualifier(
|
|
662
|
+
return lead + ', ' + cadence + trailingQualifier(schedule, opts);
|
|
506
663
|
}
|
|
507
664
|
|
|
508
|
-
const times = hourTimesFromPlan(
|
|
665
|
+
const times = hourTimesFromPlan(schedule, plan.times, true, opts);
|
|
509
666
|
|
|
510
|
-
return lead + ', at ' + times + trailingQualifier(
|
|
667
|
+
return lead + ', at ' + times + trailingQualifier(schedule, opts);
|
|
511
668
|
}
|
|
512
669
|
|
|
513
670
|
// Spelled ordinals for "during every Nth hour" — the clean hour-step
|
|
@@ -530,34 +687,35 @@ function everyNthHour(segment: StepSegment, opts: NormalizedOptions): string {
|
|
|
530
687
|
// A minute wildcard or plain range under an hour step. A wildcard minute (a
|
|
531
688
|
// cadence) is reached only for a clean step and is confined to every Nth hour;
|
|
532
689
|
// a plain range is a per-hour window whose recurrence trails as its own clause.
|
|
533
|
-
function renderMinuteSpanAcrossHourStep(
|
|
690
|
+
function renderMinuteSpanAcrossHourStep(schedule: Schedule,
|
|
534
691
|
plan: PlanOf<'minuteSpanAcrossHourStep'>, opts: NormalizedOptions): string {
|
|
535
692
|
// This plan is reached only under a stepped hour field, whose first
|
|
536
693
|
// segment is a step segment.
|
|
537
|
-
const segment =
|
|
694
|
+
const segment = stepSegment(schedule, 'hour');
|
|
538
695
|
|
|
539
696
|
// A wildcard minute over a stepped hour is reached only for a clean stride
|
|
540
697
|
// (a bounded or uneven step routes through minutesAcrossHours instead), so it
|
|
541
698
|
// confines to every Nth hour without a bounded-cadence case here.
|
|
542
699
|
if (plan.form === 'wildcard') {
|
|
543
700
|
return 'every minute ' + everyNthHour(segment, opts) +
|
|
544
|
-
trailingQualifier(
|
|
701
|
+
trailingQualifier(schedule, opts);
|
|
545
702
|
}
|
|
546
703
|
|
|
547
704
|
// A minute list keeps the same cadence clause; only its lead differs. An
|
|
548
705
|
// offset/uneven step the core enumerated to that list reads as a stride.
|
|
549
706
|
const lead = plan.form === 'list' ?
|
|
550
|
-
strideFromSegments(
|
|
551
|
-
|
|
707
|
+
strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
|
|
708
|
+
opts) ??
|
|
709
|
+
listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
|
|
552
710
|
'minute', 'hour', opts) :
|
|
553
|
-
minuteRangeLead(
|
|
711
|
+
minuteRangeLead(schedule.pattern.minute, opts);
|
|
554
712
|
// A bounded or uneven hour step reads as its endpoint-pinning cadence after
|
|
555
713
|
// the minute lead, not a wall of clock-time columns; an offset-clean step
|
|
556
714
|
// keeps its existing per-step phrasing.
|
|
557
|
-
const cadence = unevenHourCadence(
|
|
715
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
558
716
|
|
|
559
717
|
return lead + ', ' +
|
|
560
|
-
(cadence ?? stepHours(segment, opts)) + trailingQualifier(
|
|
718
|
+
(cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
|
|
561
719
|
}
|
|
562
720
|
|
|
563
721
|
// Lead phrase for a plain minute range: "every minute from <a> through <b>
|
|
@@ -573,60 +731,60 @@ function minuteRangeLead(minuteField: string,
|
|
|
573
731
|
|
|
574
732
|
// --- Hour renderers. ---
|
|
575
733
|
|
|
576
|
-
function renderEveryHour(
|
|
734
|
+
function renderEveryHour(schedule: Schedule, plan: PlanOf<'everyHour'>,
|
|
577
735
|
opts: NormalizedOptions): string {
|
|
578
|
-
return 'every hour' + trailingQualifier(
|
|
736
|
+
return 'every hour' + trailingQualifier(schedule, opts);
|
|
579
737
|
}
|
|
580
738
|
|
|
581
739
|
// An hour range fires within a window: on the hour it reads "every hour
|
|
582
740
|
// from 9 a.m. through 5 p.m."; a minute wildcard or range fires every
|
|
583
741
|
// minute; a discrete minute anchors as a lead clause.
|
|
584
|
-
function renderHourRange(
|
|
742
|
+
function renderHourRange(schedule: Schedule, plan: PlanOf<'hourRange'>,
|
|
585
743
|
opts: NormalizedOptions): string {
|
|
586
744
|
const window = hourWindow(boundedWindow(plan), opts);
|
|
587
745
|
|
|
588
746
|
if (plan.minuteForm === 'wildcard') {
|
|
589
|
-
return 'every minute ' + window + trailingQualifier(
|
|
747
|
+
return 'every minute ' + window + trailingQualifier(schedule, opts);
|
|
590
748
|
}
|
|
591
749
|
|
|
592
750
|
if (plan.minuteForm === 'range') {
|
|
593
|
-
return minuteRangeLead(
|
|
594
|
-
trailingQualifier(
|
|
751
|
+
return minuteRangeLead(schedule.pattern.minute, opts) + ', ' + window +
|
|
752
|
+
trailingQualifier(schedule, opts);
|
|
595
753
|
}
|
|
596
754
|
|
|
597
|
-
return rangeMinuteLead(
|
|
598
|
-
trailingQualifier(
|
|
755
|
+
return rangeMinuteLead(schedule, opts) + ' ' + window +
|
|
756
|
+
trailingQualifier(schedule, opts);
|
|
599
757
|
}
|
|
600
758
|
|
|
601
759
|
// Lead phrase for a discrete minute within an hour range: on-the-hour
|
|
602
760
|
// reads "every hour"; otherwise the minute list anchors it.
|
|
603
|
-
function rangeMinuteLead(
|
|
604
|
-
if (
|
|
761
|
+
function rangeMinuteLead(schedule: Schedule, opts: NormalizedOptions): string {
|
|
762
|
+
if (schedule.pattern.minute === '0') {
|
|
605
763
|
return 'every hour';
|
|
606
764
|
}
|
|
607
765
|
|
|
608
766
|
// A non-"0" minute here is a discrete list, which has segments; an
|
|
609
767
|
// offset/uneven step enumerated to that list reads as a stride.
|
|
610
|
-
return strideFromSegments(
|
|
611
|
-
opts) ?? listPastThe(segmentWords(
|
|
768
|
+
return strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
|
|
769
|
+
opts) ?? listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
|
|
612
770
|
'minute', 'hour', opts);
|
|
613
771
|
}
|
|
614
772
|
|
|
615
|
-
function renderHourStep(
|
|
773
|
+
function renderHourStep(schedule: Schedule, plan: PlanOf<'hourStep'>,
|
|
616
774
|
opts: NormalizedOptions): string {
|
|
617
775
|
// A bounded or uneven hour step reads as its endpoint-pinning cadence ("every
|
|
618
776
|
// two hours from 9 a.m. through 5 p.m."), the same form the compound paths
|
|
619
777
|
// speak; an offset-clean step keeps its bare or "from M" cadence.
|
|
620
|
-
const cadence = unevenHourCadence(
|
|
778
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
621
779
|
|
|
622
780
|
if (cadence !== null) {
|
|
623
|
-
return cadence + trailingQualifier(
|
|
781
|
+
return cadence + trailingQualifier(schedule, opts);
|
|
624
782
|
}
|
|
625
783
|
|
|
626
784
|
// An hour-step plan is selected only for a stepped hour field, whose
|
|
627
785
|
// first segment is a step segment.
|
|
628
|
-
return stepHours(
|
|
629
|
-
trailingQualifier(
|
|
786
|
+
return stepHours(stepSegment(schedule, 'hour'), opts) +
|
|
787
|
+
trailingQualifier(schedule, opts);
|
|
630
788
|
}
|
|
631
789
|
|
|
632
790
|
// The hour-range plan as a window. The close lands on the top of the final
|
|
@@ -634,31 +792,42 @@ function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
|
|
|
634
792
|
// a wildcard minute, which fills every minute and states no separate clause.
|
|
635
793
|
// A pinned/listed/ranged minute is named in its own lead clause, so folding it
|
|
636
794
|
// into the close too would read as a span ("through 5:05 p.m.") that
|
|
637
|
-
// contradicts the minute clause; the window stays bare ("through 5 p.m.").
|
|
795
|
+
// contradicts the minute clause; the window stays bare ("through 5 p.m."). The
|
|
796
|
+
// same wildcard minute is what makes the run CONTINUOUS to the top of the next
|
|
797
|
+
// hour, so it also drives the until-window choice in `rangeWindow`.
|
|
638
798
|
function boundedWindow(plan: PlanOf<'hourRange'>):
|
|
639
|
-
{from: number; to: number;
|
|
640
|
-
const
|
|
799
|
+
{from: number; to: number; closeMinute: number; continuous: boolean} {
|
|
800
|
+
const continuous = plan.minuteForm === 'wildcard';
|
|
801
|
+
const closeMinute = continuous ? plan.boundMinute ?? 0 : 0;
|
|
641
802
|
|
|
642
|
-
return {from: plan.from,
|
|
803
|
+
return {from: plan.from, closeMinute, to: plan.to, continuous};
|
|
643
804
|
}
|
|
644
805
|
|
|
645
806
|
// A contiguous hour range as a window phrase. The default English dialect
|
|
646
|
-
// reads a MULTI-hour range
|
|
647
|
-
// until 6 p.m." (the close
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
|
|
807
|
+
// reads a MULTI-hour range whose run is CONTINUOUS to the top of the next hour
|
|
808
|
+
// as an up-to-but-not-including window — "from 9 a.m. until 6 p.m." (the close
|
|
809
|
+
// is the top of the hour after the last, the sense English uses for time
|
|
810
|
+
// windows: 9-17 runs until 6 p.m.); 23 wraps to midnight. The run is continuous
|
|
811
|
+
// only when the minute is wildcard, so every minute of the final hour fires; a
|
|
812
|
+
// restricted minute fires at discrete points (e.g. only `:00`), so the run
|
|
813
|
+
// stops within the final hour and the default dialect reverts to the bare
|
|
814
|
+
// "through <last hour>" span (the minute is named in its own lead clause, so
|
|
815
|
+
// the close stays on the top of the final hour rather than restating a last
|
|
816
|
+
// fire). Every other dialect (and the compact `short` form) always speaks the
|
|
817
|
+
// span, closing on the minute field's last fire within the final hour. A
|
|
818
|
+
// single-hour sub-hour window (`from === to`, e.g. */15 9 firing 9:00 through
|
|
819
|
+
// 9:45) is NOT a multi-hour range: its close is a real fire inside the hour, so
|
|
820
|
+
// it always keeps "through" — naming "until 10 a.m." would overstate the span
|
|
821
|
+
// past the last fire.
|
|
822
|
+
function rangeWindow(window: HourWindowSpec,
|
|
656
823
|
opts: NormalizedOptions): string {
|
|
824
|
+
const {from, to, throughMinute, continuous} = window;
|
|
657
825
|
const open = 'from ' + getTime({hour: from, minute: 0}, opts);
|
|
658
826
|
|
|
659
827
|
if (opts.style.untilWindow && !opts.short && from !== to) {
|
|
660
|
-
return
|
|
661
|
-
getTime({hour: (to + 1) % 24, minute: 0}, opts)
|
|
828
|
+
return continuous ?
|
|
829
|
+
open + ' until ' + getTime({hour: (to + 1) % 24, minute: 0}, opts) :
|
|
830
|
+
open + through(opts) + getTime({hour: to, minute: 0}, opts);
|
|
662
831
|
}
|
|
663
832
|
|
|
664
833
|
return open + through(opts) +
|
|
@@ -666,24 +835,31 @@ function rangeWindow(from: number, to: number, throughMinute: number | string,
|
|
|
666
835
|
}
|
|
667
836
|
|
|
668
837
|
// An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m." (or "from 9 a.m.
|
|
669
|
-
// until 6 p.m." in the default dialect
|
|
670
|
-
// hour and close at the minute field's last fire
|
|
671
|
-
|
|
838
|
+
// until 6 p.m." in the default dialect, when the minute is wildcard). Windows
|
|
839
|
+
// open at the top of the first hour and close at the minute field's last fire
|
|
840
|
+
// within the final hour.
|
|
841
|
+
function hourWindow(
|
|
842
|
+
window: {from: number; to: number; closeMinute: number; continuous: boolean},
|
|
672
843
|
opts: NormalizedOptions): string {
|
|
673
|
-
return rangeWindow(
|
|
844
|
+
return rangeWindow({
|
|
845
|
+
continuous: window.continuous,
|
|
846
|
+
from: window.from,
|
|
847
|
+
throughMinute: window.closeMinute,
|
|
848
|
+
to: window.to
|
|
849
|
+
}, opts);
|
|
674
850
|
}
|
|
675
851
|
|
|
676
852
|
// Expand a discrete set of hours and minutes into clock times prefixed by
|
|
677
853
|
// a day-level qualifier, e.g. "every day at 9 a.m. and 9:30 a.m.".
|
|
678
|
-
function renderClockTimes(
|
|
854
|
+
function renderClockTimes(schedule: Schedule, plan: PlanOf<'clockTimes'>,
|
|
679
855
|
opts: NormalizedOptions): string {
|
|
680
856
|
// An hour step or range (or arithmetic-progression hour list) under a
|
|
681
857
|
// single pinned minute reads as a cadence or window rather than a
|
|
682
858
|
// cross-product of clock times.
|
|
683
|
-
if (
|
|
684
|
-
const minute = +
|
|
685
|
-
const cadence = hourCadence(
|
|
686
|
-
hourRangeCadence(
|
|
859
|
+
if (schedule.shapes.minute === 'single') {
|
|
860
|
+
const minute = +schedule.pattern.minute;
|
|
861
|
+
const cadence = hourCadence(schedule, minute, opts) ??
|
|
862
|
+
hourRangeCadence(schedule, minute, opts);
|
|
687
863
|
|
|
688
864
|
if (cadence !== null) {
|
|
689
865
|
return cadence;
|
|
@@ -700,28 +876,28 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
|
700
876
|
}, opts);
|
|
701
877
|
});
|
|
702
878
|
|
|
703
|
-
return interpretDayQualifier(
|
|
704
|
-
dayUnionTrail(
|
|
879
|
+
return interpretDayQualifier(schedule, opts) + 'at ' + joinList(times, opts) +
|
|
880
|
+
dayUnionTrail(schedule, opts);
|
|
705
881
|
}
|
|
706
882
|
|
|
707
883
|
// The trailing day-union condition for a clock-time form (which leads with its
|
|
708
884
|
// time, not a day qualifier), or an empty string when the pattern is not a day
|
|
709
885
|
// union. The cadence renderers carry this through `trailingQualifier` instead.
|
|
710
|
-
function dayUnionTrail(
|
|
711
|
-
return isDayUnion(
|
|
886
|
+
function dayUnionTrail(schedule: Schedule, opts: NormalizedOptions): string {
|
|
887
|
+
return isDayUnion(schedule, opts) ? dayUnionCondition(schedule, opts) : '';
|
|
712
888
|
}
|
|
713
889
|
|
|
714
890
|
// Compact form for a clock-time set past the enumeration cap. A single
|
|
715
891
|
// minute folds into per-segment hour windows; a minute list leads with its
|
|
716
892
|
// own clause instead of cross-multiplying into a wall of times.
|
|
717
|
-
function renderCompactClockTimes(
|
|
718
|
-
opts: NormalizedOptions): string {
|
|
893
|
+
function renderCompactClockTimes(schedule: Schedule,
|
|
894
|
+
plan: PlanOf<'compactClockTimes'>, opts: NormalizedOptions): string {
|
|
719
895
|
if (plan.fold) {
|
|
720
896
|
// An hour step or range (or arithmetic-progression hour list) under the
|
|
721
897
|
// single pinned minute reads as a cadence or window, not a wall of clock
|
|
722
898
|
// times. (Returns null for an irregular list, which keeps folding below.)
|
|
723
|
-
const cadence = hourCadence(
|
|
724
|
-
hourRangeCadence(
|
|
899
|
+
const cadence = hourCadence(schedule, +plan.minute, opts) ??
|
|
900
|
+
hourRangeCadence(schedule, +plan.minute, opts);
|
|
725
901
|
|
|
726
902
|
if (cadence !== null) {
|
|
727
903
|
return cadence;
|
|
@@ -729,113 +905,109 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
|
729
905
|
|
|
730
906
|
// A compact clock-time plan is reached only for discrete hours, which
|
|
731
907
|
// have segments.
|
|
732
|
-
const hasRange =
|
|
908
|
+
const hasRange = segmentsOf(schedule, 'hour').some(function range(segment) {
|
|
733
909
|
return segment.kind === 'range';
|
|
734
910
|
});
|
|
735
911
|
|
|
736
912
|
// A contiguous hour range reads with the hour-range frame ("every
|
|
737
913
|
// hour from X through Y"), not a clock-time span ("at X through Y").
|
|
738
|
-
if (hasRange && !
|
|
739
|
-
return foldedHourWindows(
|
|
914
|
+
if (hasRange && !schedule.analyses.clockSecond) {
|
|
915
|
+
return foldedHourWindows(schedule, plan, opts) +
|
|
916
|
+
trailingQualifier(schedule, opts);
|
|
740
917
|
}
|
|
741
918
|
|
|
742
|
-
const fold = {minute: plan.minute, second:
|
|
919
|
+
const fold = {minute: plan.minute, second: schedule.analyses.clockSecond};
|
|
743
920
|
|
|
744
|
-
return interpretDayQualifier(
|
|
745
|
-
hourSegmentTimes(
|
|
921
|
+
return interpretDayQualifier(schedule, opts) + 'at ' +
|
|
922
|
+
hourSegmentTimes(schedule, fold, true, opts) +
|
|
923
|
+
dayUnionTrail(schedule, opts);
|
|
746
924
|
}
|
|
747
925
|
|
|
748
926
|
const minuteLead =
|
|
749
927
|
// The non-fold branch is a minute list, which has segments. An
|
|
750
928
|
// offset/uneven step enumerated to that list reads as a stride.
|
|
751
|
-
strideFromSegments(
|
|
752
|
-
|
|
929
|
+
strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'hour',
|
|
930
|
+
opts) ??
|
|
931
|
+
listPastThe(segmentWords(segmentsOf(schedule, 'minute'), opts),
|
|
753
932
|
'minute', 'hour', opts);
|
|
754
933
|
// A uneven hour stride reads as a cadence after the minute lead, not a wall
|
|
755
934
|
// of clock-time columns.
|
|
756
|
-
const cadence = unevenHourCadence(
|
|
935
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
757
936
|
const phrase = cadence ?
|
|
758
|
-
minuteLead + ', ' + cadence + trailingQualifier(
|
|
937
|
+
minuteLead + ', ' + cadence + trailingQualifier(schedule, opts) :
|
|
759
938
|
minuteLead +
|
|
760
|
-
', at ' +
|
|
761
|
-
|
|
939
|
+
', at ' +
|
|
940
|
+
hourSegmentTimes(schedule, {minute: 0, second: null}, true, opts) +
|
|
941
|
+
trailingQualifier(schedule, opts);
|
|
762
942
|
|
|
763
943
|
// A single non-zero second cannot fold into the per-minute clause, so it
|
|
764
944
|
// leads with its own.
|
|
765
|
-
return
|
|
766
|
-
secondsLeadClause(
|
|
945
|
+
return schedule.analyses.clockSecond ?
|
|
946
|
+
secondsLeadClause(schedule, opts) + ', ' + phrase :
|
|
767
947
|
phrase;
|
|
768
948
|
}
|
|
769
949
|
|
|
770
950
|
// A folded hour field that includes a contiguous range reads with the
|
|
771
951
|
// hour-range frame: a shared minute lead ("every hour" / "at 30 minutes
|
|
772
952
|
// past the hour"), each range as a window, and any non-contiguous hour
|
|
773
|
-
// appended by `outlierTail` (
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
opts: NormalizedOptions): string {
|
|
953
|
+
// appended by `outlierTail` ("and at Z").
|
|
954
|
+
function foldedHourWindows(schedule: Schedule,
|
|
955
|
+
plan: PlanOf<'compactClockTimes'>, opts: NormalizedOptions): string {
|
|
777
956
|
const minute = plan.minute;
|
|
778
957
|
const windows: string[] = [];
|
|
779
|
-
const
|
|
780
|
-
const times = outliers.hours.map(function time(hour) {
|
|
958
|
+
const times = collectHourOutliers(schedule).map(function time(hour) {
|
|
781
959
|
return getTime({hour, minute}, opts);
|
|
782
960
|
});
|
|
783
961
|
|
|
784
|
-
// Reached only via the fold branch under discrete hours, which have
|
|
785
|
-
//
|
|
786
|
-
|
|
962
|
+
// Reached only via the fold branch under discrete hours, which have segments.
|
|
963
|
+
// A folded minute is a discrete pin/list, never a wildcard, so the run is not
|
|
964
|
+
// continuous to the top of the next hour: the window is not an until-window.
|
|
965
|
+
segmentsOf(schedule, 'hour').forEach(function classify(segment) {
|
|
787
966
|
if (segment.kind === 'range') {
|
|
788
|
-
windows.push(rangeWindow(
|
|
789
|
-
|
|
967
|
+
windows.push(rangeWindow({
|
|
968
|
+
continuous: false,
|
|
969
|
+
from: +segment.bounds[0],
|
|
970
|
+
throughMinute: minute,
|
|
971
|
+
to: +segment.bounds[1]
|
|
972
|
+
}, opts));
|
|
790
973
|
}
|
|
791
974
|
});
|
|
792
975
|
|
|
793
|
-
const phrase = rangeMinuteLead(
|
|
976
|
+
const phrase = rangeMinuteLead(schedule, opts) + ' ' +
|
|
977
|
+
joinList(windows, opts);
|
|
794
978
|
|
|
795
|
-
return phrase + outlierTail(times,
|
|
979
|
+
return phrase + outlierTail(times, opts);
|
|
796
980
|
}
|
|
797
981
|
|
|
798
|
-
// The hours outside a contiguous run — every non-range segment's values
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
// "plus" idiom does not fit and the additive list keeps "and at".
|
|
802
|
-
function collectHourOutliers(ir: IR):
|
|
803
|
-
{hours: number[]; pureStrays: boolean} {
|
|
982
|
+
// The hours outside a contiguous run — every non-range segment's values, with
|
|
983
|
+
// a step contributing its whole fire set.
|
|
984
|
+
function collectHourOutliers(schedule: Schedule): number[] {
|
|
804
985
|
const hours: number[] = [];
|
|
805
|
-
let pureStrays = true;
|
|
806
986
|
|
|
807
987
|
// Reached only under discrete hours, which carry segments.
|
|
808
|
-
|
|
988
|
+
segmentsOf(schedule, 'hour').forEach(function classify(segment) {
|
|
809
989
|
if (segment.kind === 'step') {
|
|
810
990
|
hours.push(...segment.fires);
|
|
811
|
-
pureStrays = false;
|
|
812
991
|
}
|
|
813
992
|
else if (segment.kind !== 'range') {
|
|
814
993
|
hours.push(+segment.value);
|
|
815
994
|
}
|
|
816
995
|
});
|
|
817
996
|
|
|
818
|
-
return
|
|
997
|
+
return hours;
|
|
819
998
|
}
|
|
820
999
|
|
|
821
|
-
// Join the outlier hour times that follow a contiguous-run window
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
|
|
827
|
-
// a "through <last fire>" span rather than the until-window.
|
|
828
|
-
function outlierTail(times: string[], pureStrays: boolean,
|
|
829
|
-
opts: NormalizedOptions): string {
|
|
1000
|
+
// Join the outlier hour times that follow a contiguous-run window — the hours
|
|
1001
|
+
// outside the run, enumerated as "and at 10 p.m.". (A fold always carries a
|
|
1002
|
+
// restricted minute, so its run reads the "through" span, never the
|
|
1003
|
+
// until-window; the additive "plus" idiom that paired with the until-window no
|
|
1004
|
+
// longer applies here.)
|
|
1005
|
+
function outlierTail(times: string[], opts: NormalizedOptions): string {
|
|
830
1006
|
if (!times.length) {
|
|
831
1007
|
return '';
|
|
832
1008
|
}
|
|
833
1009
|
|
|
834
|
-
|
|
835
|
-
' plus ' :
|
|
836
|
-
' and at ';
|
|
837
|
-
|
|
838
|
-
return connector + joinList(times, opts);
|
|
1010
|
+
return ' and at ' + joinList(times, opts);
|
|
839
1011
|
}
|
|
840
1012
|
|
|
841
1013
|
// --- Confinement frame. ---
|
|
@@ -864,19 +1036,19 @@ function isCadenceField(token: string): boolean {
|
|
|
864
1036
|
// the pattern has no cadence lead (the finest restricted field is a clock-point
|
|
865
1037
|
// single/range/list). The seconds lead when restricted as a cadence; otherwise
|
|
866
1038
|
// the minute leads when the second is a plain :00 and the minute is a cadence.
|
|
867
|
-
function leadingCadence(
|
|
1039
|
+
function leadingCadence(schedule: Schedule, opts: NormalizedOptions):
|
|
868
1040
|
{text: string; secondLead: boolean} | null {
|
|
869
|
-
const {second, minute} =
|
|
1041
|
+
const {second, minute} = schedule.pattern;
|
|
870
1042
|
|
|
871
1043
|
if (isCadenceField(second)) {
|
|
872
|
-
return {secondLead: true, text: secondsClause(
|
|
1044
|
+
return {secondLead: true, text: secondsClause(schedule, 'minute', opts)};
|
|
873
1045
|
}
|
|
874
1046
|
|
|
875
1047
|
if (second === '0' && isCadenceField(minute)) {
|
|
876
1048
|
const text = minute === '*' ?
|
|
877
1049
|
'every minute' :
|
|
878
1050
|
// A clean minute step's first segment is a step segment.
|
|
879
|
-
stepCycle60(
|
|
1051
|
+
stepCycle60(stepSegment(schedule, 'minute'),
|
|
880
1052
|
'minute', 'hour', opts);
|
|
881
1053
|
|
|
882
1054
|
return {secondLead: false, text};
|
|
@@ -889,8 +1061,9 @@ function leadingCadence(ir: IR, opts: NormalizedOptions):
|
|
|
889
1061
|
// confinement: "during minute :NN", "during minutes :NN through :MM", "during
|
|
890
1062
|
// minutes :NN and :MM". A clean minute step reads "of every other minute". A
|
|
891
1063
|
// wildcard minute is redundant under the seconds cadence and drops (empty).
|
|
892
|
-
function minuteConfinement(
|
|
893
|
-
|
|
1064
|
+
function minuteConfinement(schedule: Schedule,
|
|
1065
|
+
opts: NormalizedOptions): string {
|
|
1066
|
+
const minute = schedule.pattern.minute;
|
|
894
1067
|
|
|
895
1068
|
if (minute === '*') {
|
|
896
1069
|
return '';
|
|
@@ -905,13 +1078,13 @@ function minuteConfinement(ir: IR, opts: NormalizedOptions): string {
|
|
|
905
1078
|
// A minute single/range/list under the seconds lead. The minute reads as a
|
|
906
1079
|
// ":NN" clock-minute confinement, never "N minutes past the hour" (that is
|
|
907
1080
|
// the minute-lead clock-point form).
|
|
908
|
-
const segments =
|
|
1081
|
+
const segments = segmentsOf(schedule, 'minute');
|
|
909
1082
|
|
|
910
|
-
if (
|
|
1083
|
+
if (schedule.shapes.minute === 'single') {
|
|
911
1084
|
return ' during minute :' + pad(minute);
|
|
912
1085
|
}
|
|
913
1086
|
|
|
914
|
-
if (
|
|
1087
|
+
if (schedule.shapes.minute === 'range') {
|
|
915
1088
|
const bounds = minute.split('-');
|
|
916
1089
|
|
|
917
1090
|
return ' during minutes :' + pad(bounds[0]) + through(opts) + ':' +
|
|
@@ -932,15 +1105,15 @@ function minuteConfinement(ir: IR, opts: NormalizedOptions): string {
|
|
|
932
1105
|
// ("of the midnight hour"). A clean hour step is "of every other hour"; a range
|
|
933
1106
|
// reuses the until-window; a list or stepped range reads "during the … hours".
|
|
934
1107
|
// A wildcard hour drops (empty).
|
|
935
|
-
function hourConfinement(
|
|
936
|
-
const hour =
|
|
1108
|
+
function hourConfinement(schedule: Schedule, opts: NormalizedOptions): string {
|
|
1109
|
+
const hour = schedule.pattern.hour;
|
|
937
1110
|
|
|
938
1111
|
if (hour === '*') {
|
|
939
1112
|
// A pinned minute confinement ("during minute :00") repeats across every
|
|
940
1113
|
// hour, so the hour is named as the unit of recurrence; a stepped minute
|
|
941
1114
|
// ("of every other minute") or absent minute already implies all hours.
|
|
942
|
-
const minutePinned =
|
|
943
|
-
!isCadenceField(
|
|
1115
|
+
const minutePinned = schedule.pattern.minute !== '*' &&
|
|
1116
|
+
!isCadenceField(schedule.pattern.minute);
|
|
944
1117
|
|
|
945
1118
|
return minutePinned ? ' of every hour' : '';
|
|
946
1119
|
}
|
|
@@ -949,10 +1122,10 @@ function hourConfinement(ir: IR, opts: NormalizedOptions): string {
|
|
|
949
1122
|
return hour === '*/2' ? ' of every other hour' : '';
|
|
950
1123
|
}
|
|
951
1124
|
|
|
952
|
-
if (
|
|
1125
|
+
if (schedule.shapes.hour === 'single') {
|
|
953
1126
|
const h = +hour;
|
|
954
1127
|
|
|
955
|
-
if (
|
|
1128
|
+
if (schedule.shapes.minute === 'step') {
|
|
956
1129
|
return ' from ' + getTime({hour: h, minute: 0}, opts) + ' until ' +
|
|
957
1130
|
getTime({hour: (h + 1) % 24, minute: 0}, opts);
|
|
958
1131
|
}
|
|
@@ -960,93 +1133,98 @@ function hourConfinement(ir: IR, opts: NormalizedOptions): string {
|
|
|
960
1133
|
// A pinned minute confinement already named the minute, so the hour reads
|
|
961
1134
|
// as a plain clock point; a wildcard or absent minute makes the hour the
|
|
962
1135
|
// unit of recurrence ("of the midnight hour").
|
|
963
|
-
if (
|
|
1136
|
+
if (schedule.pattern.minute !== '*' &&
|
|
1137
|
+
!isCadenceField(schedule.pattern.minute)) {
|
|
964
1138
|
return ' at ' + getTime({hour: h, minute: 0}, opts);
|
|
965
1139
|
}
|
|
966
1140
|
|
|
967
1141
|
return ' of the ' + getTime({hour: h, minute: 0}, opts) + ' hour';
|
|
968
1142
|
}
|
|
969
1143
|
|
|
970
|
-
if (
|
|
1144
|
+
if (schedule.shapes.hour === 'range') {
|
|
971
1145
|
const bounds = hour.split('-');
|
|
972
1146
|
|
|
973
|
-
|
|
1147
|
+
// The until-window holds only when the run is continuous to the top of the
|
|
1148
|
+
// next hour — a wildcard minute fills every minute of the final hour; a
|
|
1149
|
+
// confined minute (":00", a step) stops within it, reading "through".
|
|
1150
|
+
return ' ' + rangeWindow({
|
|
1151
|
+
continuous: schedule.pattern.minute === '*',
|
|
1152
|
+
from: +bounds[0],
|
|
1153
|
+
throughMinute: 0,
|
|
1154
|
+
to: +bounds[1]
|
|
1155
|
+
}, opts);
|
|
974
1156
|
}
|
|
975
1157
|
|
|
976
1158
|
// An hour list or stepped range reads "during the <times> hours".
|
|
977
1159
|
return ' during the ' +
|
|
978
|
-
hourSegmentTimes(
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
// Whether the hour field reads as a contiguous window — a real range whose
|
|
982
|
-
// close depends on the finer field's last fire. A finer STEP cadence does not
|
|
983
|
-
// fill the closing hour ("from 9 a.m. until 5:45 p.m."), so that window is left
|
|
984
|
-
// to the existing windowing renderer rather than the confinement frame, which
|
|
985
|
-
// closes on the top of the next hour ("until 6 p.m.").
|
|
986
|
-
function isContiguousHourRange(ir: IR): boolean {
|
|
987
|
-
return ir.shapes.hour === 'range';
|
|
1160
|
+
hourSegmentTimes(schedule, {minute: 0, second: null}, false, opts) +
|
|
1161
|
+
' hours';
|
|
988
1162
|
}
|
|
989
1163
|
|
|
990
1164
|
// Whether an hour field is confinement-eligible. An OPEN hour stride — a clean
|
|
991
1165
|
// `*/n`, an offset `m/n`, or a uneven step — reads as a cadence ("every three
|
|
992
|
-
// hours from 2 a.m."), and only the `*/2` form has a
|
|
993
|
-
// ("of every other hour"), so other open steps defer. A BOUNDED stepped
|
|
994
|
-
// (`a-b/n`, e.g. `9-17/2`) is a discrete set of named hours the
|
|
995
|
-
// frame speaks as a list ("during the 9 a.m., 11 a.m., … hours").
|
|
996
|
-
function confinableHour(
|
|
997
|
-
if (
|
|
1166
|
+
// hours from 2 a.m."), and only the `*/2` form has a dedicated confinement
|
|
1167
|
+
// idiom ("of every other hour"), so other open steps defer. A BOUNDED stepped
|
|
1168
|
+
// range (`a-b/n`, e.g. `9-17/2`) is a discrete set of named hours the
|
|
1169
|
+
// confinement frame speaks as a list ("during the 9 a.m., 11 a.m., … hours").
|
|
1170
|
+
function confinableHour(schedule: Schedule): boolean {
|
|
1171
|
+
if (schedule.shapes.hour !== 'step') {
|
|
998
1172
|
return true;
|
|
999
1173
|
}
|
|
1000
1174
|
|
|
1001
1175
|
// Reached only under a stepped hour, whose first segment is a step segment.
|
|
1002
|
-
const segment =
|
|
1176
|
+
const segment = stepSegment(schedule, 'hour');
|
|
1003
1177
|
|
|
1004
|
-
return
|
|
1178
|
+
return schedule.pattern.hour === '*/2' ||
|
|
1179
|
+
segment.startToken.indexOf('-') !== -1;
|
|
1005
1180
|
}
|
|
1006
1181
|
|
|
1007
1182
|
// Whether a minute list is really a stride the existing renderer speaks as a
|
|
1008
1183
|
// cadence ("every two minutes from 3 through 59"): such a progression is not a
|
|
1009
1184
|
// short explicit ":NN" confinement, so it defers.
|
|
1010
|
-
function isMinuteStride(
|
|
1011
|
-
if (
|
|
1185
|
+
function isMinuteStride(schedule: Schedule): boolean {
|
|
1186
|
+
if (schedule.shapes.minute !== 'list') {
|
|
1012
1187
|
return false;
|
|
1013
1188
|
}
|
|
1014
1189
|
|
|
1015
|
-
const values = singleValues(
|
|
1190
|
+
const values = singleValues(segmentsOf(schedule, 'minute'));
|
|
1016
1191
|
|
|
1017
1192
|
return values !== null && arithmeticStep(values) !== null;
|
|
1018
1193
|
}
|
|
1019
1194
|
|
|
1020
|
-
// Whether the pattern is in the
|
|
1021
|
-
// covers a finer leading cadence (seconds, or minute under a :00 second)
|
|
1022
|
-
// each coarser field as a confinement; shapes outside
|
|
1023
|
-
//
|
|
1024
|
-
function confinementEligible(
|
|
1195
|
+
// Whether the pattern is in the confinement frame's supported shape-set. The
|
|
1196
|
+
// frame covers a finer leading cadence (seconds, or minute under a :00 second)
|
|
1197
|
+
// with each coarser field as a confinement; shapes outside it defer to the
|
|
1198
|
+
// existing renderers, which already produce that phrasing for them.
|
|
1199
|
+
function confinementEligible(schedule: Schedule,
|
|
1025
1200
|
lead: {secondLead: boolean}): boolean {
|
|
1026
|
-
const {minute, hour} =
|
|
1201
|
+
const {minute, hour} = schedule.pattern;
|
|
1027
1202
|
const minuteStep = isCadenceField(minute) && minute !== '*';
|
|
1028
1203
|
|
|
1029
1204
|
// A non-`*/2` hour stride keeps the existing cadence form.
|
|
1030
|
-
if (!confinableHour(
|
|
1205
|
+
if (!confinableHour(schedule)) {
|
|
1031
1206
|
return false;
|
|
1032
1207
|
}
|
|
1033
1208
|
|
|
1034
1209
|
if (lead.secondLead) {
|
|
1035
|
-
// A minute STEP is
|
|
1210
|
+
// A minute STEP is supported only as the `*/2` "every other minute" idiom,
|
|
1036
1211
|
// and only where it fills the coarser field: a contiguous hour range or a
|
|
1037
1212
|
// single hour both close on the minute's real last fire, which the
|
|
1038
1213
|
// windowing renderer already speaks. The `*/2` step fills both, so it keeps
|
|
1039
|
-
// the "of every other minute" confinement; other steps defer entirely.
|
|
1214
|
+
// the "of every other minute" confinement; other steps defer entirely. A
|
|
1215
|
+
// contiguous hour range (`hour === 'range'`) is left to that windowing
|
|
1216
|
+
// renderer rather than this confinement frame, which closes on the top of
|
|
1217
|
+
// the next hour.
|
|
1040
1218
|
if (minuteStep) {
|
|
1041
|
-
return minute === '*/2' &&
|
|
1219
|
+
return minute === '*/2' && schedule.shapes.hour !== 'range';
|
|
1042
1220
|
}
|
|
1043
1221
|
|
|
1044
1222
|
// A minute list that is really a stride keeps its cadence form; a short
|
|
1045
1223
|
// explicit minute list crossed with a discrete hour LIST is a wall of
|
|
1046
1224
|
// distinct clock times ("9:00 a.m., 9:25 a.m., …"), not a single minute
|
|
1047
1225
|
// confinement. Both stay with the enumerating renderer.
|
|
1048
|
-
if (isMinuteStride(
|
|
1049
|
-
|
|
1226
|
+
if (isMinuteStride(schedule) ||
|
|
1227
|
+
schedule.shapes.minute === 'list' && schedule.shapes.hour === 'list') {
|
|
1050
1228
|
return false;
|
|
1051
1229
|
}
|
|
1052
1230
|
|
|
@@ -1054,7 +1232,7 @@ function confinementEligible(ir: IR,
|
|
|
1054
1232
|
}
|
|
1055
1233
|
|
|
1056
1234
|
// A minute-LEAD cadence (second :00). The existing renderers already produce
|
|
1057
|
-
//
|
|
1235
|
+
// that phrasing for a single/range/list hour and for a non-`*/2` hour
|
|
1058
1236
|
// step; the confinement frame only changes the `*/2` hour ("of every other
|
|
1059
1237
|
// hour") and the single hour under an "every other minute" step ("from
|
|
1060
1238
|
// midnight until 1 a.m."). Everything else defers.
|
|
@@ -1062,13 +1240,15 @@ function confinementEligible(ir: IR,
|
|
|
1062
1240
|
return true;
|
|
1063
1241
|
}
|
|
1064
1242
|
|
|
1065
|
-
return
|
|
1243
|
+
return schedule.shapes.hour === 'single' && minute === '*/2';
|
|
1066
1244
|
}
|
|
1067
1245
|
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
// renderers in place of the older juxtaposed-cadence and
|
|
1071
|
-
|
|
1246
|
+
// Render the pattern with the confinement frame: a finer leading cadence with
|
|
1247
|
+
// each coarser field as a confinement, or null when it does not apply. Routed
|
|
1248
|
+
// to from the cadence renderers in place of the older juxtaposed-cadence and
|
|
1249
|
+
// duration-frame forms.
|
|
1250
|
+
function confinement(schedule: Schedule,
|
|
1251
|
+
opts: NormalizedOptions): string | null {
|
|
1072
1252
|
// The confinement frame is scoped to the default (US) dialect, the one that
|
|
1073
1253
|
// carries the until-window; every other dialect and the compact `short` form
|
|
1074
1254
|
// keep their established juxtaposed-cadence / duration-frame phrasing.
|
|
@@ -1079,20 +1259,20 @@ function confinement(ir: IR, opts: NormalizedOptions): string | null {
|
|
|
1079
1259
|
// With nothing coarser to confine (minute and hour both wildcard), the bare
|
|
1080
1260
|
// cadence renderers already speak the pattern ("every second", "every
|
|
1081
1261
|
// minute"); the confinement frame only applies once a coarser field is set.
|
|
1082
|
-
if (
|
|
1262
|
+
if (schedule.pattern.minute === '*' && schedule.pattern.hour === '*') {
|
|
1083
1263
|
return null;
|
|
1084
1264
|
}
|
|
1085
1265
|
|
|
1086
|
-
const lead = leadingCadence(
|
|
1266
|
+
const lead = leadingCadence(schedule, opts);
|
|
1087
1267
|
|
|
1088
|
-
if (!lead || !confinementEligible(
|
|
1268
|
+
if (!lead || !confinementEligible(schedule, lead)) {
|
|
1089
1269
|
return null;
|
|
1090
1270
|
}
|
|
1091
1271
|
|
|
1092
|
-
const minutePart = lead.secondLead ? minuteConfinement(
|
|
1272
|
+
const minutePart = lead.secondLead ? minuteConfinement(schedule, opts) : '';
|
|
1093
1273
|
|
|
1094
|
-
return lead.text + minutePart + hourConfinement(
|
|
1095
|
-
trailingQualifier(
|
|
1274
|
+
return lead.text + minutePart + hourConfinement(schedule, opts) +
|
|
1275
|
+
trailingQualifier(schedule, opts);
|
|
1096
1276
|
}
|
|
1097
1277
|
|
|
1098
1278
|
// The plan dispatch table.
|
|
@@ -1132,47 +1312,30 @@ const renderers = {
|
|
|
1132
1312
|
function renderStride(stride: Stride, opts: NormalizedOptions): string {
|
|
1133
1313
|
const {interval, start, last, cycle, unit, anchor} = stride;
|
|
1134
1314
|
const cadence = 'every ' + getNumber(interval, opts) + ' ' + unit + 's';
|
|
1135
|
-
const tiles = cycle % interval === 0;
|
|
1136
1315
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
}
|
|
1316
|
+
return chooseStride({start, interval, cycle}, {
|
|
1317
|
+
bare: () => cadence,
|
|
1140
1318
|
|
|
1141
|
-
if (start < interval && tiles) {
|
|
1142
1319
|
// A clean wrap from a non-zero offset: name the start, no endpoint.
|
|
1143
|
-
|
|
1144
|
-
pluralize(start, unit) + ' past the ' + anchor
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
// A bounded, non-wrapping set: pin both endpoints. Each bound is a value, so
|
|
1148
|
-
// it reads as a digit, matching the range idiom ("from 0 through 30").
|
|
1149
|
-
const num = seriesNumber();
|
|
1320
|
+
offset: () => cadence + ' from ' + getNumber(start, opts) + ' ' +
|
|
1321
|
+
pluralize(start, unit) + ' past the ' + anchor,
|
|
1150
1322
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
// The sorted numeric values a field's segments cover, or null if any segment
|
|
1156
|
-
// is not a discrete single (a range or sub-step is not a plain fire list).
|
|
1157
|
-
function singleValues(segments: Segment[]): number[] | null {
|
|
1158
|
-
const values: number[] = [];
|
|
1323
|
+
// A bounded, non-wrapping set: pin both endpoints. Each bound is a value,
|
|
1324
|
+
// so it reads as a digit, matching the range idiom ("from 0 through 30").
|
|
1325
|
+
bounded: () => {
|
|
1326
|
+
const num = seriesNumber();
|
|
1159
1327
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
return null;
|
|
1328
|
+
return cadence + ' from ' + num(start) + through(opts) + num(last) + ' ' +
|
|
1329
|
+
pluralize(last, unit) + ' past the ' + anchor;
|
|
1163
1330
|
}
|
|
1164
|
-
|
|
1165
|
-
values.push(+segment.value);
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
return values;
|
|
1331
|
+
});
|
|
1169
1332
|
}
|
|
1170
1333
|
|
|
1171
1334
|
// Speak a minute/second field's enumerated fires as a step cadence when they
|
|
1172
1335
|
// form an arithmetic progression long enough to beat the list (the core
|
|
1173
|
-
// enumerates an offset/uneven step to this fire list; the
|
|
1174
|
-
// the renderer recognizes the progression). Returns null for a
|
|
1175
|
-
// or a too-short list, leaving the caller to enumerate.
|
|
1336
|
+
// enumerates an offset/uneven step to this fire list; the Schedule is
|
|
1337
|
+
// unchanged, so the renderer recognizes the progression). Returns null for a
|
|
1338
|
+
// non-progression or a too-short list, leaving the caller to enumerate.
|
|
1176
1339
|
function strideFromSegments(segments: Segment[], unit: string, anchor: string,
|
|
1177
1340
|
opts: NormalizedOptions): string | null {
|
|
1178
1341
|
const values = singleValues(segments);
|
|
@@ -1255,29 +1418,14 @@ function hourStrideCadence(stride: {start: number; interval: number;
|
|
|
1255
1418
|
last: number}, opts: NormalizedOptions): string {
|
|
1256
1419
|
const {start, interval, last} = stride;
|
|
1257
1420
|
const cadence = 'every ' + getNumber(interval, opts) + ' hours';
|
|
1258
|
-
const tiles = 24 % interval === 0;
|
|
1259
|
-
|
|
1260
|
-
if (start === 0 && tiles) {
|
|
1261
|
-
return cadence;
|
|
1262
|
-
}
|
|
1263
1421
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
// Whether an hour stride wraps the day cleanly from within its first interval
|
|
1273
|
-
// (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
|
|
1274
|
-
// stride has no distinct endpoint and keeps its bare or "from M" cadence. Every
|
|
1275
|
-
// other stride — a uneven interval, or one starting at or past its interval
|
|
1276
|
-
// (a bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
|
|
1277
|
-
function offsetCleanStride(
|
|
1278
|
-
stride: {start: number; interval: number}
|
|
1279
|
-
): boolean {
|
|
1280
|
-
return stride.start < stride.interval && 24 % stride.interval === 0;
|
|
1422
|
+
return chooseStride({start, interval, cycle: 24}, {
|
|
1423
|
+
bare: () => cadence,
|
|
1424
|
+
offset: () => cadence + ' from ' + getTime({hour: start, minute: 0}, opts),
|
|
1425
|
+
bounded: () =>
|
|
1426
|
+
cadence + ' from ' + getTime({hour: start, minute: 0}, opts) +
|
|
1427
|
+
through(opts) + getTime({hour: last, minute: 0}, opts)
|
|
1428
|
+
});
|
|
1281
1429
|
}
|
|
1282
1430
|
|
|
1283
1431
|
// The bounded cadence for an hour stride that pins both clock-time endpoints,
|
|
@@ -1287,8 +1435,9 @@ function offsetCleanStride(
|
|
|
1287
1435
|
// ("…, every five hours from midnight through 8 p.m.") than as a wall of
|
|
1288
1436
|
// clock-time columns. An offset-clean stride keeps its existing confinement
|
|
1289
1437
|
// form, so only the endpoint-bearing case routes here.
|
|
1290
|
-
function unevenHourCadence(
|
|
1291
|
-
|
|
1438
|
+
function unevenHourCadence(schedule: Schedule,
|
|
1439
|
+
opts: NormalizedOptions): string | null {
|
|
1440
|
+
const stride = hourStride(schedule);
|
|
1292
1441
|
|
|
1293
1442
|
if (!stride || offsetCleanStride(stride)) {
|
|
1294
1443
|
return null;
|
|
@@ -1297,51 +1446,17 @@ function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
|
|
|
1297
1446
|
return hourStrideCadence(stride, opts);
|
|
1298
1447
|
}
|
|
1299
1448
|
|
|
1300
|
-
// An hour list's arithmetic progression, or null when its values are not a
|
|
1301
|
-
// step the renderer should speak as a cadence. The core rewrites a uneven hour
|
|
1302
|
-
// step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
|
|
1303
|
-
// literal fire list, indistinguishable in the IR from a hand-written list; the
|
|
1304
|
-
// renderer recovers the cadence from the values. A progression starting at
|
|
1305
|
-
// zero is a `*/n` step however short (0,7,14,21 is `*/7`); a non-zero one is
|
|
1306
|
-
// only a step when it is too long to be a deliberate clock-time list (e.g.
|
|
1307
|
-
// 9,17 is two named times, not a cadence), the same length the minute/second
|
|
1308
|
-
// list path uses. Interval one is a plain range, never a step.
|
|
1309
|
-
function hourListStride(values: number[]):
|
|
1310
|
-
{start: number; interval: number; last: number} | null {
|
|
1311
|
-
if (values.length < 2) {
|
|
1312
|
-
return null;
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
const interval = values[1] - values[0];
|
|
1316
|
-
|
|
1317
|
-
if (interval < 2) {
|
|
1318
|
-
return null;
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
for (let i = 2; i < values.length; i += 1) {
|
|
1322
|
-
if (values[i] - values[i - 1] !== interval) {
|
|
1323
|
-
return null;
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
if (values[0] !== 0 && values.length < 5) {
|
|
1328
|
-
return null;
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
return {interval, last: values[values.length - 1], start: values[0]};
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
1449
|
// The hour field's stride, or null when the hour is not a cadence: a step
|
|
1335
1450
|
// segment yields its {start, interval, last} directly; an all-single hour
|
|
1336
1451
|
// list yields one only when its values form a step progression (so an irregular
|
|
1337
|
-
// list like 9,17 keeps enumerating). The
|
|
1452
|
+
// list like 9,17 keeps enumerating). The Schedule is unchanged — the renderer
|
|
1338
1453
|
// recognizes the stride and speaks it as a cadence instead of the clock-time
|
|
1339
1454
|
// cross-product.
|
|
1340
|
-
function hourStride(
|
|
1455
|
+
function hourStride(schedule: Schedule):
|
|
1341
1456
|
{start: number; interval: number; last: number} | null {
|
|
1342
1457
|
// Reached only from the clock-time paths, which run under discrete hours
|
|
1343
1458
|
// and so always carry hour segments.
|
|
1344
|
-
const segments =
|
|
1459
|
+
const segments = segmentsOf(schedule, 'hour');
|
|
1345
1460
|
|
|
1346
1461
|
if (segments.length === 1 && segments[0].kind === 'step') {
|
|
1347
1462
|
const segment = segments[0];
|
|
@@ -1368,8 +1483,8 @@ function hourStride(ir: IR):
|
|
|
1368
1483
|
// The second's status against a pinned minute: a wildcard or sub-minute step
|
|
1369
1484
|
// fills the minute (a "for one minute" frame at minute 0); a single 0 is just
|
|
1370
1485
|
// the top of the minute (no clause); anything else needs its own clause.
|
|
1371
|
-
function subMinuteSecond(
|
|
1372
|
-
return
|
|
1486
|
+
function subMinuteSecond(schedule: Schedule): boolean {
|
|
1487
|
+
return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
|
|
1373
1488
|
}
|
|
1374
1489
|
|
|
1375
1490
|
// The lead clause for an hour-cadence rendering: the second and the pinned
|
|
@@ -1379,14 +1494,14 @@ function subMinuteSecond(ir: IR): boolean {
|
|
|
1379
1494
|
// minute" frame (the whole minute-0 window). A non-zero minute is a real
|
|
1380
1495
|
// clock minute: the second leads with its own "past the minute" clause (if
|
|
1381
1496
|
// any), then the minute reads "M minutes past the hour".
|
|
1382
|
-
function hourCadenceLead(
|
|
1497
|
+
function hourCadenceLead(schedule: Schedule, minute: number,
|
|
1383
1498
|
opts: NormalizedOptions): string {
|
|
1384
1499
|
if (minute === 0) {
|
|
1385
|
-
if (subMinuteSecond(
|
|
1386
|
-
return secondsClause(
|
|
1500
|
+
if (subMinuteSecond(schedule)) {
|
|
1501
|
+
return secondsClause(schedule, 'minute', opts) + ' for one minute';
|
|
1387
1502
|
}
|
|
1388
1503
|
|
|
1389
|
-
return secondsClause(
|
|
1504
|
+
return secondsClause(schedule, 'hour', opts);
|
|
1390
1505
|
}
|
|
1391
1506
|
|
|
1392
1507
|
const minutePhrase = getNumber(minute, opts) + ' ' +
|
|
@@ -1394,11 +1509,11 @@ function hourCadenceLead(ir: IR, minute: number,
|
|
|
1394
1509
|
|
|
1395
1510
|
// A single 0 second is just the top of the minute, so the minute leads
|
|
1396
1511
|
// alone; any other second prefixes its own clause.
|
|
1397
|
-
if (
|
|
1512
|
+
if (schedule.pattern.second === '0') {
|
|
1398
1513
|
return minutePhrase;
|
|
1399
1514
|
}
|
|
1400
1515
|
|
|
1401
|
-
return secondsClause(
|
|
1516
|
+
return secondsClause(schedule, 'minute', opts) + ', ' + minutePhrase;
|
|
1402
1517
|
}
|
|
1403
1518
|
|
|
1404
1519
|
// Render an hour step (or arithmetic-progression hour list) under a single
|
|
@@ -1410,10 +1525,10 @@ function hourCadenceLead(ir: IR, minute: number,
|
|
|
1410
1525
|
// but a plain :00) makes every clock time three digit-groups, so any stride
|
|
1411
1526
|
// is worth compacting; otherwise the stride must exceed the clock-time cap,
|
|
1412
1527
|
// the same point at which the core itself stops enumerating. Renderer-only;
|
|
1413
|
-
// the
|
|
1414
|
-
function hourCadence(
|
|
1528
|
+
// the Schedule is unchanged.
|
|
1529
|
+
function hourCadence(schedule: Schedule, minute: number,
|
|
1415
1530
|
opts: NormalizedOptions): string | null {
|
|
1416
|
-
const stride = hourStride(
|
|
1531
|
+
const stride = hourStride(schedule);
|
|
1417
1532
|
|
|
1418
1533
|
if (!stride) {
|
|
1419
1534
|
return null;
|
|
@@ -1426,7 +1541,7 @@ function hourCadence(ir: IR, minute: number,
|
|
|
1426
1541
|
// or "from M" form is no shorter than the list, so the list reads fine. A
|
|
1427
1542
|
// bounded or uneven stride has no clean wrap, so its endpoint-pinning cadence
|
|
1428
1543
|
// ("every five hours from midnight through 8 p.m.") reads better however few.
|
|
1429
|
-
if (
|
|
1544
|
+
if (schedule.pattern.second === '0' && fires <= maxClockTimes &&
|
|
1430
1545
|
offsetCleanStride(stride)) {
|
|
1431
1546
|
return null;
|
|
1432
1547
|
}
|
|
@@ -1436,33 +1551,33 @@ function hourCadence(ir: IR, minute: number,
|
|
|
1436
1551
|
// minute during every other hour", matching the "every minute during every
|
|
1437
1552
|
// other hour" idiom and keeping it distinct from the bare hour-step form
|
|
1438
1553
|
// ("every two hours") so the minute-0 confinement is never heard as it.
|
|
1439
|
-
const minuteZeroStride = minute === 0 && subMinuteSecond(
|
|
1440
|
-
cleanStrideSegment(
|
|
1554
|
+
const minuteZeroStride = minute === 0 && subMinuteSecond(schedule) &&
|
|
1555
|
+
cleanStrideSegment(schedule);
|
|
1441
1556
|
|
|
1442
1557
|
if (minuteZeroStride) {
|
|
1443
|
-
return secondsClause(
|
|
1444
|
-
everyNthHour(minuteZeroStride, opts) + trailingQualifier(
|
|
1558
|
+
return secondsClause(schedule, 'minute', opts) + ' for one minute ' +
|
|
1559
|
+
everyNthHour(minuteZeroStride, opts) + trailingQualifier(schedule, opts);
|
|
1445
1560
|
}
|
|
1446
1561
|
|
|
1447
1562
|
// A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
|
|
1448
1563
|
// lead clause to fold in, so the bounded cadence stands on its own ("every
|
|
1449
1564
|
// five hours from midnight through 8 p.m."); only a real minute or second
|
|
1450
1565
|
// prefixes its clause.
|
|
1451
|
-
if (minute === 0 &&
|
|
1452
|
-
return hourStrideCadence(stride, opts) + trailingQualifier(
|
|
1566
|
+
if (minute === 0 && schedule.pattern.second === '0') {
|
|
1567
|
+
return hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
|
|
1453
1568
|
}
|
|
1454
1569
|
|
|
1455
|
-
return hourCadenceLead(
|
|
1456
|
-
hourStrideCadence(stride, opts) + trailingQualifier(
|
|
1570
|
+
return hourCadenceLead(schedule, minute, opts) + ', ' +
|
|
1571
|
+
hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
|
|
1457
1572
|
}
|
|
1458
1573
|
|
|
1459
1574
|
// The hour step segment when the hour is a clean stride with an idiomatic
|
|
1460
1575
|
// ordinal ("every other", "every sixth"), suitable for the "during every Nth
|
|
1461
1576
|
// hour" confinement frame; null otherwise (an uneven stride, a bounded step,
|
|
1462
1577
|
// or an arithmetic-progression list, which keep the bounded cadence form).
|
|
1463
|
-
function cleanStrideSegment(
|
|
1578
|
+
function cleanStrideSegment(schedule: Schedule): StepSegment | null {
|
|
1464
1579
|
// Reached only after hourStride confirmed a stride, so hour segments exist.
|
|
1465
|
-
const segments =
|
|
1580
|
+
const segments = segmentsOf(schedule, 'hour');
|
|
1466
1581
|
const segment = segments.length === 1 && segments[0];
|
|
1467
1582
|
|
|
1468
1583
|
if (!segment || segment.kind !== 'step' ||
|
|
@@ -1479,38 +1594,44 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
|
|
|
1479
1594
|
// A pure single-value list (9,17) has no range to span and still enumerates;
|
|
1480
1595
|
// a step is handled by hourStride/hourCadence, so a field whose only segments
|
|
1481
1596
|
// are steps and singles is left alone here.
|
|
1482
|
-
function hasHourWindow(
|
|
1597
|
+
function hasHourWindow(schedule: Schedule): boolean {
|
|
1483
1598
|
// Reached only from the clock-time paths, which run under discrete hours
|
|
1484
1599
|
// and so always carry hour segments.
|
|
1485
|
-
return
|
|
1600
|
+
return segmentsOf(schedule, 'hour').some(function range(segment) {
|
|
1486
1601
|
return segment.kind === 'range';
|
|
1487
1602
|
});
|
|
1488
1603
|
}
|
|
1489
1604
|
|
|
1490
1605
|
// The hour-range window as a cadence tail at the top of each hour: each range
|
|
1491
|
-
// segment is a window ("every hour from 9 a.m.
|
|
1492
|
-
// non-contiguous single hour is appended by `outlierTail` ("
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
// hour. Mirrors foldedHourWindows but
|
|
1496
|
-
|
|
1606
|
+
// segment is a window ("every hour from 9 a.m. through 8 p.m."), and any
|
|
1607
|
+
// non-contiguous single hour is appended by `outlierTail` ("and at 10 p.m.").
|
|
1608
|
+
// The minute has already folded into the "every hour" lead — a single pinned
|
|
1609
|
+
// minute, never a wildcard — so the run is not continuous to the top of the
|
|
1610
|
+
// next hour and the window keeps "through". Mirrors foldedHourWindows but
|
|
1611
|
+
// pinned to minute 0.
|
|
1612
|
+
function hourRangeWindowTail(schedule: Schedule,
|
|
1613
|
+
opts: NormalizedOptions): string {
|
|
1497
1614
|
const windows: string[] = [];
|
|
1498
|
-
const
|
|
1615
|
+
const outlierHours = collectHourOutliers(schedule);
|
|
1499
1616
|
|
|
1500
1617
|
// Reached only after hasHourWindow, so hour segments exist.
|
|
1501
|
-
|
|
1618
|
+
segmentsOf(schedule, 'hour').forEach(function classify(segment) {
|
|
1502
1619
|
if (segment.kind === 'range') {
|
|
1503
|
-
windows.push(rangeWindow(
|
|
1504
|
-
|
|
1620
|
+
windows.push(rangeWindow({
|
|
1621
|
+
continuous: false,
|
|
1622
|
+
from: +segment.bounds[0],
|
|
1623
|
+
throughMinute: 0,
|
|
1624
|
+
to: +segment.bounds[1]
|
|
1625
|
+
}, opts));
|
|
1505
1626
|
}
|
|
1506
1627
|
});
|
|
1507
1628
|
|
|
1508
1629
|
const phrase = 'every hour ' + joinList(windows, opts);
|
|
1509
|
-
const times =
|
|
1630
|
+
const times = outlierHours.map(function time(hour) {
|
|
1510
1631
|
return getTime({hour, minute: 0}, opts);
|
|
1511
1632
|
});
|
|
1512
1633
|
|
|
1513
|
-
return phrase + outlierTail(times,
|
|
1634
|
+
return phrase + outlierTail(times, opts);
|
|
1514
1635
|
}
|
|
1515
1636
|
|
|
1516
1637
|
// Render an hour range (or a list whose segments include a range) under a
|
|
@@ -1519,21 +1640,21 @@ function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
|
|
|
1519
1640
|
// the hours into a wall of clock times. Returns null when the hour has no
|
|
1520
1641
|
// range (a pure single-value list, a single hour, or a step, which other
|
|
1521
1642
|
// paths own), or when a plain :00 set is short enough that enumeration is no
|
|
1522
|
-
// longer than the window. Renderer-only; the
|
|
1523
|
-
function hourRangeCadence(
|
|
1643
|
+
// longer than the window. Renderer-only; the Schedule is unchanged.
|
|
1644
|
+
function hourRangeCadence(schedule: Schedule, minute: number,
|
|
1524
1645
|
opts: NormalizedOptions): string | null {
|
|
1525
1646
|
// Scoped to minute 0: the minute folds into the lead and every hour fires
|
|
1526
1647
|
// at the top, so the window closes cleanly on the final hour. A non-zero
|
|
1527
1648
|
// pinned minute is a real clock minute the existing clock-time window form
|
|
1528
1649
|
// already speaks ("9:30:15 a.m. through 8:30:15 p.m."), unchanged.
|
|
1529
|
-
if (minute !== 0 || !hasHourWindow(
|
|
1650
|
+
if (minute !== 0 || !hasHourWindow(schedule)) {
|
|
1530
1651
|
return null;
|
|
1531
1652
|
}
|
|
1532
1653
|
|
|
1533
1654
|
// A plain top-of-minute second (:00) carries no clause: the existing
|
|
1534
1655
|
// hour-range and folded-window renderers already speak that window, so this
|
|
1535
1656
|
// path only forms a window when there is a meaningful second to lead with.
|
|
1536
|
-
if (
|
|
1657
|
+
if (schedule.pattern.second === '0') {
|
|
1537
1658
|
return null;
|
|
1538
1659
|
}
|
|
1539
1660
|
|
|
@@ -1543,14 +1664,15 @@ function hourRangeCadence(ir: IR, minute: number,
|
|
|
1543
1664
|
// uses). This is kept distinct from the bare minute-0 window ("every hour
|
|
1544
1665
|
// from 9 a.m. through 5 p.m.") so the one-minute confinement is never heard
|
|
1545
1666
|
// as it — the hour-range analog of "for one minute during every other hour".
|
|
1546
|
-
if (subMinuteSecond(
|
|
1547
|
-
return secondsClause(
|
|
1548
|
-
|
|
1549
|
-
|
|
1667
|
+
if (subMinuteSecond(schedule)) {
|
|
1668
|
+
return secondsClause(schedule, 'minute', opts) +
|
|
1669
|
+
' for one minute during the ' +
|
|
1670
|
+
hourSegmentTimes(schedule, {minute: 0, second: null}, false, opts) +
|
|
1671
|
+
' hours' + trailingQualifier(schedule, opts);
|
|
1550
1672
|
}
|
|
1551
1673
|
|
|
1552
|
-
return hourCadenceLead(
|
|
1553
|
-
hourRangeWindowTail(
|
|
1674
|
+
return hourCadenceLead(schedule, minute, opts) + ', ' +
|
|
1675
|
+
hourRangeWindowTail(schedule, opts) + trailingQualifier(schedule, opts);
|
|
1554
1676
|
}
|
|
1555
1677
|
|
|
1556
1678
|
// --- List and segment phrasing. ---
|
|
@@ -1666,13 +1788,13 @@ function singleHourFire(times: HourTimesPlan): boolean {
|
|
|
1666
1788
|
// The hour times accompanying a window phrase: enumerated fires up to the
|
|
1667
1789
|
// cap, segment rendering past it (decided by the core). `atContext` marks
|
|
1668
1790
|
// an "at <times>" frame (vs "during the <times> hours").
|
|
1669
|
-
function hourTimesFromPlan(
|
|
1670
|
-
opts: NormalizedOptions): string {
|
|
1791
|
+
function hourTimesFromPlan(schedule: Schedule, times: HourTimesPlan,
|
|
1792
|
+
atContext: boolean, opts: NormalizedOptions): string {
|
|
1671
1793
|
if (times.kind === 'fires') {
|
|
1672
1794
|
return hourTimes(times.fires, opts);
|
|
1673
1795
|
}
|
|
1674
1796
|
|
|
1675
|
-
return hourSegmentTimes(
|
|
1797
|
+
return hourSegmentTimes(schedule, {minute: 0, second: null}, atContext, opts);
|
|
1676
1798
|
}
|
|
1677
1799
|
|
|
1678
1800
|
// The hour values an hour segment covers: a range's bounds, a step's
|
|
@@ -1688,13 +1810,13 @@ function segmentHours(segment: Segment): (string | number)[] {
|
|
|
1688
1810
|
// Clock times for the hour field rendered segment by segment, so ranges
|
|
1689
1811
|
// read as windows ("9:30 a.m. through 8:30 p.m.") rather than an
|
|
1690
1812
|
// enumeration. The minute (and optional second) fold into each time.
|
|
1691
|
-
function hourSegmentTimes(
|
|
1813
|
+
function hourSegmentTimes(schedule: Schedule,
|
|
1692
1814
|
fold: {minute: number | string; second: number | null | undefined},
|
|
1693
1815
|
atContext: boolean, opts: NormalizedOptions): string {
|
|
1694
1816
|
const {minute, second} = fold;
|
|
1695
1817
|
// Hour-segment rendering is reached only under discrete hours, which have
|
|
1696
1818
|
// segments.
|
|
1697
|
-
const segments =
|
|
1819
|
+
const segments = segmentsOf(schedule, 'hour');
|
|
1698
1820
|
const plain = mixedTwelve(segments.flatMap(function entries(segment) {
|
|
1699
1821
|
return segmentHours(segment).map(function entry(hour) {
|
|
1700
1822
|
return {hour: +hour, minute, second};
|
|
@@ -1802,47 +1924,49 @@ const leadingWords: QualifierWords = {
|
|
|
1802
1924
|
|
|
1803
1925
|
// A trailing day-level qualifier for bare frequencies, e.g. " on Monday".
|
|
1804
1926
|
// Returns an empty string when no date, month, or weekday is set.
|
|
1805
|
-
function trailingQualifier(
|
|
1927
|
+
function trailingQualifier(schedule: Schedule,
|
|
1928
|
+
opts: NormalizedOptions): string {
|
|
1806
1929
|
// A day union reframes both day fields as a trailing condition clause; the
|
|
1807
1930
|
// month leads the whole description (applied in `describe`), so it is not
|
|
1808
1931
|
// part of the trailing qualifier here.
|
|
1809
|
-
if (isDayUnion(
|
|
1810
|
-
return dayUnionCondition(
|
|
1932
|
+
if (isDayUnion(schedule, opts)) {
|
|
1933
|
+
return dayUnionCondition(schedule, opts);
|
|
1811
1934
|
}
|
|
1812
1935
|
|
|
1813
|
-
const phrase = dayQualifier(
|
|
1936
|
+
const phrase = dayQualifier(schedule, trailingWords, opts);
|
|
1814
1937
|
|
|
1815
1938
|
return phrase && ' ' + phrase;
|
|
1816
1939
|
}
|
|
1817
1940
|
|
|
1818
1941
|
// Build the day-level qualifier that precedes a specific time, e.g.
|
|
1819
1942
|
// "every day ", "every Friday ", or "on January 13 ".
|
|
1820
|
-
function interpretDayQualifier(
|
|
1943
|
+
function interpretDayQualifier(schedule: Schedule,
|
|
1944
|
+
opts: NormalizedOptions): string {
|
|
1821
1945
|
// A day union puts the time first ("at midnight whenever the day is …"), so
|
|
1822
1946
|
// the leading position contributes no day phrase; the condition clause is
|
|
1823
1947
|
// appended after the time by the clock renderer.
|
|
1824
|
-
if (isDayUnion(
|
|
1948
|
+
if (isDayUnion(schedule, opts)) {
|
|
1825
1949
|
return '';
|
|
1826
1950
|
}
|
|
1827
1951
|
|
|
1828
|
-
return dayQualifier(
|
|
1952
|
+
return dayQualifier(schedule, leadingWords, opts) + ' ';
|
|
1829
1953
|
}
|
|
1830
1954
|
|
|
1831
1955
|
// The day-level qualifier phrase (date, month, and weekday), or
|
|
1832
1956
|
// `words.all` when all three are wildcards. `words` supplies the
|
|
1833
1957
|
// connectives that differ between the trailing and leading positions.
|
|
1834
|
-
function dayQualifier(
|
|
1958
|
+
function dayQualifier(schedule: Schedule, words: QualifierWords,
|
|
1835
1959
|
opts: NormalizedOptions): string {
|
|
1836
|
-
const pattern =
|
|
1960
|
+
const pattern = schedule.pattern;
|
|
1837
1961
|
|
|
1838
1962
|
// Standard cron fires when day-of-month OR day-of-week matches, when
|
|
1839
1963
|
// both are restricted.
|
|
1840
1964
|
if (pattern.date !== '*' && pattern.weekday !== '*') {
|
|
1841
|
-
return dateOrWeekday(
|
|
1965
|
+
return dateOrWeekday(schedule, opts);
|
|
1842
1966
|
}
|
|
1843
1967
|
|
|
1844
1968
|
if (pattern.date !== '*') {
|
|
1845
|
-
return datePhrase(
|
|
1969
|
+
return datePhrase(schedule, words, opts);
|
|
1846
1970
|
}
|
|
1847
1971
|
|
|
1848
1972
|
// A weekday qualifier, optionally scoped to a month ("on Monday in
|
|
@@ -1854,46 +1978,47 @@ function dayQualifier(ir: IR, words: QualifierWords,
|
|
|
1854
1978
|
// the "of the month" recurrence a concrete month makes redundant; a plain
|
|
1855
1979
|
// weekday name takes the ordinary " in <month>" scope.
|
|
1856
1980
|
if (quartzWeekday) {
|
|
1857
|
-
return monthScopeForRecurrence(quartzWeekday,
|
|
1981
|
+
return monthScopeForRecurrence(quartzWeekday, schedule, opts);
|
|
1858
1982
|
}
|
|
1859
1983
|
|
|
1860
1984
|
const weekdays = words.weekday +
|
|
1861
|
-
weekdayPhrase(
|
|
1985
|
+
weekdayPhrase(schedule, words.recurringWeekday, opts);
|
|
1862
1986
|
|
|
1863
|
-
return weekdays + monthScope(
|
|
1987
|
+
return weekdays + monthScope(schedule, opts);
|
|
1864
1988
|
}
|
|
1865
1989
|
|
|
1866
1990
|
if (pattern.month !== '*') {
|
|
1867
|
-
return words.month + monthName(
|
|
1991
|
+
return words.month + monthName(schedule, opts);
|
|
1868
1992
|
}
|
|
1869
1993
|
|
|
1870
1994
|
return words.all;
|
|
1871
1995
|
}
|
|
1872
1996
|
|
|
1873
1997
|
// The date portion of a day qualifier (the weekday is a wildcard).
|
|
1874
|
-
function datePhrase(
|
|
1998
|
+
function datePhrase(schedule: Schedule, words: QualifierWords,
|
|
1875
1999
|
opts: NormalizedOptions): string {
|
|
1876
|
-
const pattern =
|
|
2000
|
+
const pattern = schedule.pattern;
|
|
1877
2001
|
const quartzDate = quartzDatePhrase(pattern.date, opts);
|
|
1878
2002
|
|
|
1879
2003
|
if (quartzDate) {
|
|
1880
|
-
return monthScopeForRecurrence(quartzDate,
|
|
2004
|
+
return monthScopeForRecurrence(quartzDate, schedule, opts);
|
|
1881
2005
|
}
|
|
1882
2006
|
|
|
1883
2007
|
if (isOpenStep(pattern.date)) {
|
|
1884
2008
|
return monthScopeForRecurrence(
|
|
1885
|
-
words.stepDate + stepDates(pattern.date),
|
|
2009
|
+
words.stepDate + stepDates(pattern.date), schedule, opts);
|
|
1886
2010
|
}
|
|
1887
2011
|
|
|
1888
|
-
if (pattern.month !== '*' && !monthFoldsIntoDate(
|
|
1889
|
-
return 'on the ' + dateOrdinals(
|
|
2012
|
+
if (pattern.month !== '*' && !monthFoldsIntoDate(schedule)) {
|
|
2013
|
+
return 'on the ' + dateOrdinals(schedule, opts) +
|
|
2014
|
+
monthScope(schedule, opts);
|
|
1890
2015
|
}
|
|
1891
2016
|
|
|
1892
2017
|
if (pattern.month !== '*') {
|
|
1893
|
-
return 'on ' + monthDatePhrase(
|
|
2018
|
+
return 'on ' + monthDatePhrase(schedule, opts);
|
|
1894
2019
|
}
|
|
1895
2020
|
|
|
1896
|
-
return 'on the ' + dateOrdinals(
|
|
2021
|
+
return 'on the ' + dateOrdinals(schedule, opts);
|
|
1897
2022
|
}
|
|
1898
2023
|
|
|
1899
2024
|
// Whether the month can fold into a calendar date ("on June 1"): flat name
|
|
@@ -1902,10 +2027,10 @@ function datePhrase(ir: IR, words: QualifierWords,
|
|
|
1902
2027
|
// "(June) through (September 1)" — and the "every odd/even-numbered month"
|
|
1903
2028
|
// frequency phrase has no name to place before the date; both scope the date
|
|
1904
2029
|
// instead ("on the 1st in June through September").
|
|
1905
|
-
function monthFoldsIntoDate(
|
|
1906
|
-
return !oddEvenMonth(
|
|
2030
|
+
function monthFoldsIntoDate(schedule: Schedule): boolean {
|
|
2031
|
+
return !oddEvenMonth(schedule.pattern.month) &&
|
|
1907
2032
|
// Reached only with a restricted month, which has segments.
|
|
1908
|
-
|
|
2033
|
+
segmentsOf(schedule, 'month').every(function flat(segment) {
|
|
1909
2034
|
return segment.kind !== 'range';
|
|
1910
2035
|
});
|
|
1911
2036
|
}
|
|
@@ -1921,17 +2046,18 @@ function monthFoldsIntoDate(ir: IR): boolean {
|
|
|
1921
2046
|
// and `dayUnionCondition`), not inside the trailing/leading qualifier. Scoped
|
|
1922
2047
|
// to the until-window dialect; every other dialect and the `short` form keep
|
|
1923
2048
|
// the established "on <dom> or on <dow>" phrasing.
|
|
1924
|
-
function isDayUnion(
|
|
1925
|
-
return
|
|
2049
|
+
function isDayUnion(schedule: Schedule, opts: NormalizedOptions): boolean {
|
|
2050
|
+
return schedule.pattern.date !== '*' && schedule.pattern.weekday !== '*' &&
|
|
1926
2051
|
!!opts.style.untilWindow && !opts.short;
|
|
1927
2052
|
}
|
|
1928
2053
|
|
|
1929
2054
|
// The trailing condition clause for a day union, e.g. " whenever the day is
|
|
1930
2055
|
// the 1st or a Friday". The day predicates are flattened into one or-list so
|
|
1931
2056
|
// the union reads as a single set of matching days.
|
|
1932
|
-
function dayUnionCondition(
|
|
1933
|
-
|
|
1934
|
-
|
|
2057
|
+
function dayUnionCondition(schedule: Schedule,
|
|
2058
|
+
opts: NormalizedOptions): string {
|
|
2059
|
+
const pieces = [...dayUnionDatePieces(schedule, opts),
|
|
2060
|
+
...dayUnionWeekdayPieces(schedule, opts)];
|
|
1935
2061
|
|
|
1936
2062
|
return ' whenever the day is ' + joinOr(pieces, opts);
|
|
1937
2063
|
}
|
|
@@ -1939,12 +2065,13 @@ function dayUnionCondition(ir: IR, opts: NormalizedOptions): string {
|
|
|
1939
2065
|
// The leading "in <month> " scope for a day union, or an empty string when the
|
|
1940
2066
|
// month is a wildcard. The month scopes the whole union, so it leads the clause
|
|
1941
2067
|
// rather than attaching to either day half.
|
|
1942
|
-
function dayUnionMonthLead(
|
|
1943
|
-
|
|
2068
|
+
function dayUnionMonthLead(schedule: Schedule,
|
|
2069
|
+
opts: NormalizedOptions): string {
|
|
2070
|
+
if (schedule.pattern.month === '*') {
|
|
1944
2071
|
return '';
|
|
1945
2072
|
}
|
|
1946
2073
|
|
|
1947
|
-
return 'in ' + monthName(
|
|
2074
|
+
return 'in ' + monthName(schedule, opts) + ' ';
|
|
1948
2075
|
}
|
|
1949
2076
|
|
|
1950
2077
|
// The day-of-month half of a union as a flat list of predicate pieces. A
|
|
@@ -1952,8 +2079,9 @@ function dayUnionMonthLead(ir: IR, opts: NormalizedOptions): string {
|
|
|
1952
2079
|
// `*/2`-style step is the parity idiom ("an odd-numbered day"); a plain field
|
|
1953
2080
|
// reads each segment as "the <ordinal>" or "from the <ordinal> through the
|
|
1954
2081
|
// <ordinal>".
|
|
1955
|
-
function dayUnionDatePieces(
|
|
1956
|
-
|
|
2082
|
+
function dayUnionDatePieces(schedule: Schedule,
|
|
2083
|
+
opts: NormalizedOptions): string[] {
|
|
2084
|
+
const dateField = schedule.pattern.date;
|
|
1957
2085
|
const quartz = quartzDatePhrase(dateField, opts);
|
|
1958
2086
|
|
|
1959
2087
|
if (quartz) {
|
|
@@ -1971,7 +2099,7 @@ function dayUnionDatePieces(ir: IR, opts: NormalizedOptions): string[] {
|
|
|
1971
2099
|
// spreads its enumerated fires as separate "the <ordinal>" alternatives.
|
|
1972
2100
|
const pieces: string[] = [];
|
|
1973
2101
|
|
|
1974
|
-
|
|
2102
|
+
segmentsOf(schedule, 'date').forEach(function expand(segment) {
|
|
1975
2103
|
if (segment.kind === 'range') {
|
|
1976
2104
|
pieces.push('from the ' + getOrdinal(segment.bounds[0]) + through(opts) +
|
|
1977
2105
|
'the ' + getOrdinal(segment.bounds[1]));
|
|
@@ -1994,8 +2122,9 @@ function dayUnionDatePieces(ir: IR, opts: NormalizedOptions): string[] {
|
|
|
1994
2122
|
// through-Friday range is the "a weekday" idiom; every other weekday names each
|
|
1995
2123
|
// day with the indefinite article ("a Friday", "a Sunday"), so each reads as a
|
|
1996
2124
|
// kind of day the union can match.
|
|
1997
|
-
function dayUnionWeekdayPieces(
|
|
1998
|
-
|
|
2125
|
+
function dayUnionWeekdayPieces(schedule: Schedule,
|
|
2126
|
+
opts: NormalizedOptions): string[] {
|
|
2127
|
+
const weekdayField = schedule.pattern.weekday;
|
|
1999
2128
|
const quartz = quartzWeekdayPhrase(weekdayField, opts);
|
|
2000
2129
|
|
|
2001
2130
|
if (quartz) {
|
|
@@ -2004,11 +2133,11 @@ function dayUnionWeekdayPieces(ir: IR, opts: NormalizedOptions): string[] {
|
|
|
2004
2133
|
|
|
2005
2134
|
// The union predicate keeps the canonical Sunday-first order (0…6) rather
|
|
2006
2135
|
// than the weekend-last display order: as a flat or-list of day kinds, the
|
|
2007
|
-
// numeric order reads as naturally as any other
|
|
2008
|
-
//
|
|
2136
|
+
// numeric order reads as naturally as any other in a flat or-list ("a
|
|
2137
|
+
// Sunday, a Tuesday, a Thursday, or a Saturday").
|
|
2009
2138
|
const pieces: string[] = [];
|
|
2010
2139
|
|
|
2011
|
-
|
|
2140
|
+
segmentsOf(schedule, 'weekday').forEach(function expand(segment) {
|
|
2012
2141
|
if (segment.kind === 'range' &&
|
|
2013
2142
|
segment.bounds[0] === '1' && segment.bounds[1] === '5') {
|
|
2014
2143
|
pieces.push('a weekday');
|
|
@@ -2059,26 +2188,27 @@ function oddEvenDay(dateField: string): string | null {
|
|
|
2059
2188
|
// names itself on the weekday ("or on Friday in June"), keeping both halves
|
|
2060
2189
|
// scoped; otherwise (a Quartz date, an open day step, a month range, or the
|
|
2061
2190
|
// odd/even frequency) it trails the whole or as ", in <month>".
|
|
2062
|
-
function dateOrWeekday(
|
|
2063
|
-
const pattern =
|
|
2191
|
+
function dateOrWeekday(schedule: Schedule, opts: NormalizedOptions): string {
|
|
2192
|
+
const pattern = schedule.pattern;
|
|
2064
2193
|
// The day-of-month-OR-day-of-week union is out of scope for the recurring
|
|
2065
2194
|
// plural (it is reframed elsewhere): the weekday half stays singular here.
|
|
2066
2195
|
const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) ||
|
|
2067
|
-
'on ' + weekdayPhrase(
|
|
2196
|
+
'on ' + weekdayPhrase(schedule, false, opts);
|
|
2068
2197
|
|
|
2069
|
-
if (pattern.month !== '*' && monthFoldsIntoDate(
|
|
2198
|
+
if (pattern.month !== '*' && monthFoldsIntoDate(schedule) &&
|
|
2070
2199
|
!quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
|
|
2071
|
-
return 'on ' + monthDatePhrase(
|
|
2072
|
-
' in ' + monthName(
|
|
2200
|
+
return 'on ' + monthDatePhrase(schedule, opts) + ' or ' + weekdayPart +
|
|
2201
|
+
' in ' + monthName(schedule, opts);
|
|
2073
2202
|
}
|
|
2074
2203
|
|
|
2075
|
-
return datePart(
|
|
2204
|
+
return datePart(schedule, opts) + ' or ' + weekdayPart +
|
|
2205
|
+
orMonthScope(schedule, opts);
|
|
2076
2206
|
}
|
|
2077
2207
|
|
|
2078
2208
|
// The day-of-month half of an or-day phrase, without any month scope (the
|
|
2079
2209
|
// month scopes the whole or, applied by the caller).
|
|
2080
|
-
function datePart(
|
|
2081
|
-
const pattern =
|
|
2210
|
+
function datePart(schedule: Schedule, opts: NormalizedOptions): string {
|
|
2211
|
+
const pattern = schedule.pattern;
|
|
2082
2212
|
const quartzDate = quartzDatePhrase(pattern.date, opts);
|
|
2083
2213
|
|
|
2084
2214
|
if (quartzDate) {
|
|
@@ -2089,18 +2219,18 @@ function datePart(ir: IR, opts: NormalizedOptions): string {
|
|
|
2089
2219
|
return stepDates(pattern.date);
|
|
2090
2220
|
}
|
|
2091
2221
|
|
|
2092
|
-
return 'on the ' + dateOrdinals(
|
|
2222
|
+
return 'on the ' + dateOrdinals(schedule, opts);
|
|
2093
2223
|
}
|
|
2094
2224
|
|
|
2095
2225
|
// A trailing month scope for the whole or, set off by a comma so it reads
|
|
2096
2226
|
// over both day halves ("…or on Friday, in June"); empty when the month is a
|
|
2097
2227
|
// wildcard.
|
|
2098
|
-
function orMonthScope(
|
|
2099
|
-
if (
|
|
2228
|
+
function orMonthScope(schedule: Schedule, opts: NormalizedOptions): string {
|
|
2229
|
+
if (schedule.pattern.month === '*') {
|
|
2100
2230
|
return '';
|
|
2101
2231
|
}
|
|
2102
2232
|
|
|
2103
|
-
return ', in ' + monthName(
|
|
2233
|
+
return ', in ' + monthName(schedule, opts);
|
|
2104
2234
|
}
|
|
2105
2235
|
|
|
2106
2236
|
// The day-qualifier phrase for a Quartz date field (e.g. "on the last day
|
|
@@ -2157,16 +2287,16 @@ function quartzWeekdayPhrase(weekdayField: string,
|
|
|
2157
2287
|
// reads as if the 13 belongs to January alone. The day is reattached to the
|
|
2158
2288
|
// whole list with the possessive "the <ordinal> of <months>", which names the
|
|
2159
2289
|
// same day across every month unambiguously.
|
|
2160
|
-
function monthDatePhrase(
|
|
2161
|
-
const month = monthName(
|
|
2290
|
+
function monthDatePhrase(schedule: Schedule, opts: NormalizedOptions): string {
|
|
2291
|
+
const month = monthName(schedule, opts);
|
|
2162
2292
|
// A month-day phrase is reached only with a restricted date, which has
|
|
2163
2293
|
// segments.
|
|
2164
|
-
const days = renderSegments(
|
|
2294
|
+
const days = renderSegments(segmentsOf(schedule, 'date'),
|
|
2165
2295
|
opts.style.ordinals ? getOrdinal : cardinalDay, opts);
|
|
2166
2296
|
|
|
2167
|
-
if (opts.style.dayFirst &&
|
|
2168
|
-
|
|
2169
|
-
return 'the ' + getOrdinal(
|
|
2297
|
+
if (opts.style.dayFirst && schedule.shapes.date === 'single' &&
|
|
2298
|
+
schedule.shapes.month !== 'single') {
|
|
2299
|
+
return 'the ' + getOrdinal(schedule.pattern.date) + ' of ' + month;
|
|
2170
2300
|
}
|
|
2171
2301
|
|
|
2172
2302
|
return opts.style.dayFirst ? days + ' ' + month : month + ' ' + days;
|
|
@@ -2179,12 +2309,12 @@ function cardinalDay(value: number | string): string {
|
|
|
2179
2309
|
|
|
2180
2310
|
// A trailing " in <month>" scope, or an empty string when the month is a
|
|
2181
2311
|
// wildcard.
|
|
2182
|
-
function monthScope(
|
|
2183
|
-
if (
|
|
2312
|
+
function monthScope(schedule: Schedule, opts: NormalizedOptions): string {
|
|
2313
|
+
if (schedule.pattern.month === '*') {
|
|
2184
2314
|
return '';
|
|
2185
2315
|
}
|
|
2186
2316
|
|
|
2187
|
-
return ' in ' + monthName(
|
|
2317
|
+
return ' in ' + monthName(schedule, opts);
|
|
2188
2318
|
}
|
|
2189
2319
|
|
|
2190
2320
|
// Scope a phrase that ends in the recurrence "of the month" (the Quartz last-
|
|
@@ -2195,25 +2325,27 @@ function monthScope(ir: IR, opts: NormalizedOptions): string {
|
|
|
2195
2325
|
// distributes the recurrence across the span and keeps it, rephrased as "of
|
|
2196
2326
|
// each month from <first> through <last>". A month list is left as-is (the
|
|
2197
2327
|
// recurrence stays, scoped "in <names>"), and a wildcard month adds nothing.
|
|
2198
|
-
function monthScopeForRecurrence(phrase: string,
|
|
2328
|
+
function monthScopeForRecurrence(phrase: string, schedule: Schedule,
|
|
2199
2329
|
opts: NormalizedOptions): string {
|
|
2200
|
-
if (
|
|
2330
|
+
if (schedule.pattern.month === '*') {
|
|
2201
2331
|
return phrase;
|
|
2202
2332
|
}
|
|
2203
2333
|
|
|
2204
2334
|
const carriesRecurrence = phrase.indexOf(' of the month') !== -1;
|
|
2205
2335
|
|
|
2206
|
-
if (carriesRecurrence &&
|
|
2336
|
+
if (carriesRecurrence && schedule.shapes.month === 'range') {
|
|
2207
2337
|
return phrase.replace(' of the month', ' of each month') + ' from ' +
|
|
2208
|
-
monthName(
|
|
2338
|
+
monthName(schedule, opts);
|
|
2209
2339
|
}
|
|
2210
2340
|
|
|
2211
2341
|
if (carriesRecurrence &&
|
|
2212
|
-
(
|
|
2213
|
-
|
|
2342
|
+
(schedule.shapes.month === 'single' ||
|
|
2343
|
+
schedule.shapes.month === 'step')) {
|
|
2344
|
+
return phrase.replace(' of the month', '') + ' in ' +
|
|
2345
|
+
monthName(schedule, opts);
|
|
2214
2346
|
}
|
|
2215
2347
|
|
|
2216
|
-
return phrase + ' in ' + monthName(
|
|
2348
|
+
return phrase + ' in ' + monthName(schedule, opts);
|
|
2217
2349
|
}
|
|
2218
2350
|
|
|
2219
2351
|
// Frequency phrase for an open day-of-month step, e.g. "every other day of
|
|
@@ -2236,17 +2368,17 @@ function stepDates(dateField: string): string {
|
|
|
2236
2368
|
|
|
2237
2369
|
// Render the date field's segments as suffixed ordinals. Open steps are
|
|
2238
2370
|
// handled separately as a frequency phrase.
|
|
2239
|
-
function dateOrdinals(
|
|
2371
|
+
function dateOrdinals(schedule: Schedule, opts: NormalizedOptions): string {
|
|
2240
2372
|
// Reached only with a restricted date, which has segments.
|
|
2241
|
-
return renderSegments(
|
|
2373
|
+
return renderSegments(segmentsOf(schedule, 'date'), getOrdinal, opts);
|
|
2242
2374
|
}
|
|
2243
2375
|
|
|
2244
2376
|
// Render the month field as names. There are few, named months, so a step
|
|
2245
2377
|
// enumerates them ("January, April, July, and October") rather than reading as
|
|
2246
2378
|
// a frequency — except interval 2, which reads as "every odd/even-numbered
|
|
2247
2379
|
// month".
|
|
2248
|
-
function monthName(
|
|
2249
|
-
const oddEven = oddEvenMonth(
|
|
2380
|
+
function monthName(schedule: Schedule, opts: NormalizedOptions): string {
|
|
2381
|
+
const oddEven = oddEvenMonth(schedule.pattern.month);
|
|
2250
2382
|
|
|
2251
2383
|
if (oddEven) {
|
|
2252
2384
|
return oddEven;
|
|
@@ -2254,7 +2386,7 @@ function monthName(ir: IR, opts: NormalizedOptions): string {
|
|
|
2254
2386
|
|
|
2255
2387
|
// A restricted month has segments; open steps of interval 3+ enumerate their
|
|
2256
2388
|
// fires here too.
|
|
2257
|
-
return renderSegments(
|
|
2389
|
+
return renderSegments(segmentsOf(schedule, 'month'), function name(value) {
|
|
2258
2390
|
return getMonth(value, opts);
|
|
2259
2391
|
}, opts);
|
|
2260
2392
|
}
|
|
@@ -2288,12 +2420,12 @@ function oddEvenMonth(monthField: string): string | null {
|
|
|
2288
2420
|
// keeps the singular idiom ("on Monday through Friday") so its through-
|
|
2289
2421
|
// connective stays unmistakable, and a leading time-anchored form ("every
|
|
2290
2422
|
// Monday") is never recurring here.
|
|
2291
|
-
function weekdayPhrase(
|
|
2423
|
+
function weekdayPhrase(schedule: Schedule, recurring: boolean,
|
|
2292
2424
|
opts: NormalizedOptions): string {
|
|
2293
2425
|
// Reached only with a restricted weekday, which has segments. Weekday lists
|
|
2294
|
-
// display Monday-first (Sunday last) so a weekend reads naturally; the
|
|
2295
|
-
// stays canonical (Sunday=0) and ranges keep their form.
|
|
2296
|
-
const segments = orderWeekdaysForDisplay(
|
|
2426
|
+
// display Monday-first (Sunday last) so a weekend reads naturally; the
|
|
2427
|
+
// Schedule stays canonical (Sunday=0) and ranges keep their form.
|
|
2428
|
+
const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
|
|
2297
2429
|
const hasRange = segments.some(function range(segment) {
|
|
2298
2430
|
return segment.kind === 'range';
|
|
2299
2431
|
});
|
|
@@ -2345,21 +2477,13 @@ function renderSegments(segments: Segment[],
|
|
|
2345
2477
|
return joinList(pieces, opts);
|
|
2346
2478
|
}
|
|
2347
2479
|
|
|
2348
|
-
// Whether a canonical field value is an "open" step (`*/n` or `a/n`, not a
|
|
2349
|
-
// bounded range or a list). Open steps read as a frequency rather than an
|
|
2350
|
-
// enumeration.
|
|
2351
|
-
function isOpenStep(field: string): boolean {
|
|
2352
|
-
return field.indexOf('/') !== -1 && field.indexOf('-') === -1 &&
|
|
2353
|
-
field.indexOf(',') === -1;
|
|
2354
|
-
}
|
|
2355
|
-
|
|
2356
2480
|
// --- Years. ---
|
|
2357
2481
|
|
|
2358
2482
|
// Append or fold the year field into a finished description. An
|
|
2359
2483
|
// explicitly supplied year is always rendered.
|
|
2360
|
-
function applyYear(description: string,
|
|
2484
|
+
function applyYear(description: string, schedule: Schedule,
|
|
2361
2485
|
opts: NormalizedOptions): string {
|
|
2362
|
-
const yearField =
|
|
2486
|
+
const yearField = schedule.pattern.year;
|
|
2363
2487
|
|
|
2364
2488
|
if (yearField === '*') {
|
|
2365
2489
|
return description;
|
|
@@ -2375,7 +2499,7 @@ function applyYear(description: string, ir: IR,
|
|
|
2375
2499
|
const label = yearLabel(yearField, opts);
|
|
2376
2500
|
|
|
2377
2501
|
if (yearField.indexOf('-') === -1 && yearField.indexOf(',') === -1 &&
|
|
2378
|
-
|
|
2502
|
+
schedule.pattern.date !== '*' && description.indexOf(' at ') !== -1) {
|
|
2379
2503
|
// US dates take a comma before the year ("January 1, 2030"); UK dates
|
|
2380
2504
|
// do not ("1 January 2030").
|
|
2381
2505
|
const yearGlue = opts.style.dayFirst ? ' ' : ', ';
|
|
@@ -2545,7 +2669,7 @@ function getWeekday(d: number | string, opts: NormalizedOptions): string {
|
|
|
2545
2669
|
return (weekday && weekday[opts.short ? 1 : 0]) as string;
|
|
2546
2670
|
}
|
|
2547
2671
|
|
|
2548
|
-
// The English language module: the
|
|
2672
|
+
// The English language module: the Schedule renderer plus the language-owned
|
|
2549
2673
|
// strings and option normalization.
|
|
2550
2674
|
const en: Language = {
|
|
2551
2675
|
describe,
|