cronli5 0.1.5 → 0.1.7
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 +71 -0
- package/README.md +2 -2
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +156 -27
- package/dist/cronli5.js +156 -27
- package/dist/lang/de.cjs +157 -36
- package/dist/lang/de.js +157 -36
- package/dist/lang/en.cjs +156 -27
- package/dist/lang/en.js +156 -27
- package/dist/lang/es.cjs +156 -18
- package/dist/lang/es.js +156 -18
- package/dist/lang/fi.cjs +128 -20
- package/dist/lang/fi.js +128 -20
- package/dist/lang/zh.cjs +126 -58
- package/dist/lang/zh.js +126 -58
- package/package.json +2 -2
- package/src/core/util.ts +52 -1
- package/src/lang/de/index.ts +331 -74
- package/src/lang/en/index.ts +327 -62
- package/src/lang/es/index.ts +306 -39
- package/src/lang/fi/index.ts +251 -41
- package/src/lang/zh/index.ts +246 -105
- package/types/core/util.d.ts +10 -1
package/src/lang/en/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the core stays semantic, and this module's only input is the IR.
|
|
4
4
|
// See docs/i18n-design.md.
|
|
5
5
|
|
|
6
|
-
import {arithmeticStep} from '../../core/util.js';
|
|
6
|
+
import {arithmeticStep, orderWeekdaysForDisplay} from '../../core/util.js';
|
|
7
7
|
import {maxClockTimes} from '../../core/specs.js';
|
|
8
8
|
import {clockDigits, numeral} from '../../core/format.js';
|
|
9
9
|
import type {Cronli5Options} from '../../types.js';
|
|
@@ -197,14 +197,41 @@ function composeHourCadence(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
|
197
197
|
const clockRest = plan.rest.kind === 'clockTimes' ||
|
|
198
198
|
plan.rest.kind === 'compactClockTimes';
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
if (!clockRest || ir.shapes.minute !== 'single') {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const minute = +ir.pattern.minute;
|
|
205
|
+
|
|
206
|
+
return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
// A meaningful second under minute/hour shapes the earlier strategies
|
|
206
210
|
// deferred on: the second leads with its own clause and the rest of the
|
|
207
211
|
// pattern follows.
|
|
212
|
+
// A wildcard or stepped second under a fixed minute across one or more specific
|
|
213
|
+
// hours. The clock-time rest collapses the pinned minute into the hour, and on
|
|
214
|
+
// the clock a pinned minute-0 reads as the whole hour ("9 a.m." spoken ==
|
|
215
|
+
// "9:00 a.m."), losing the one-minute confinement.
|
|
216
|
+
//
|
|
217
|
+
// A SINGLE minute-0 is the one-minute window at the top of each named hour: a
|
|
218
|
+
// duration frame ("for one minute at 9 a.m.") states the confinement outright,
|
|
219
|
+
// with the hour as its word so it cannot be heard as the hour itself. A minute
|
|
220
|
+
// LIST whose first value is 0 (e.g. */25 → :00, :25, :50) is a wall of distinct
|
|
221
|
+
// clock times, not one confinement, so it names each minute via the compact
|
|
222
|
+
// form, never collapsing to the bare hour (which once repeated it, "9 a.m.,
|
|
223
|
+
// 9 a.m."). A non-zero pinned minute is an unambiguous clock time the compact
|
|
224
|
+
// "of 9:05 a.m." form reads as the minute, never the hour.
|
|
225
|
+
function clockTimesConfinement(ir: IR, rest: PlanOf<'clockTimes'>,
|
|
226
|
+
opts: NormalizedOptions): string {
|
|
227
|
+
if (+rest.times[0].minute === 0 && ir.shapes.minute === 'single') {
|
|
228
|
+
return secondsLeadClause(ir, opts) + ' for one minute at ' +
|
|
229
|
+
durationHours(ir, rest, opts);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return secondsLeadClause(ir, opts) + ' of ' + clockTimesOf(ir, rest, opts);
|
|
233
|
+
}
|
|
234
|
+
|
|
208
235
|
function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
209
236
|
opts: NormalizedOptions): string {
|
|
210
237
|
// An hour step (or arithmetic-progression hour list) under a single pinned
|
|
@@ -217,28 +244,11 @@ function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
|
217
244
|
return cadence;
|
|
218
245
|
}
|
|
219
246
|
|
|
220
|
-
// A wildcard or stepped second under a minute
|
|
221
|
-
//
|
|
222
|
-
// pinned minute into the hour, and on the clock a pinned minute-0 reads as
|
|
223
|
-
// the whole hour ("9 a.m." spoken == "9:00 a.m."), losing the one-minute
|
|
224
|
-
// confinement. (A second list/range/single leads with a "past the minute"
|
|
225
|
-
// clause that an "of"/duration frame cannot follow, so it stays generic.)
|
|
247
|
+
// A wildcard or stepped second under a fixed minute across one or more
|
|
248
|
+
// specific hours confines the seconds to the clock time(s).
|
|
226
249
|
if (plan.rest.kind === 'clockTimes' &&
|
|
227
250
|
(ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// Minute 0 is the one-minute window at the top of each named hour: a
|
|
231
|
-
// duration frame ("for one minute at 9 a.m.") states the confinement
|
|
232
|
-
// outright, with the hour as its word so it cannot be heard as the hour
|
|
233
|
-
// itself. A non-zero pinned minute is an unambiguous clock time, so the
|
|
234
|
-
// compact "of 9:05 a.m." form reads it as the minute, never the hour.
|
|
235
|
-
if (+minute === 0) {
|
|
236
|
-
return secondsLeadClause(ir, opts) + ' for one minute at ' +
|
|
237
|
-
durationHours(ir, plan.rest, opts);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return secondsLeadClause(ir, opts) + ' of ' +
|
|
241
|
-
clockTimesOf(ir, plan.rest, opts);
|
|
251
|
+
return clockTimesConfinement(ir, plan.rest, opts);
|
|
242
252
|
}
|
|
243
253
|
|
|
244
254
|
// A wildcard second under a */2 minute step with a wildcard hour binds
|
|
@@ -254,7 +264,15 @@ function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
|
254
264
|
trailingQualifier(ir, opts);
|
|
255
265
|
}
|
|
256
266
|
|
|
257
|
-
|
|
267
|
+
// A compact clock-time rest folds a meaningful SINGLE second into its own
|
|
268
|
+
// leading clause, so the composer must not prepend a second lead that would
|
|
269
|
+
// double it. A wildcard or stepped second is not folded there (no
|
|
270
|
+
// clockSecond), so it still leads its own clause here.
|
|
271
|
+
const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
|
|
272
|
+
ir.analyses.clockSecond;
|
|
273
|
+
const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
|
|
274
|
+
|
|
275
|
+
return lead + render(ir, plan.rest, opts);
|
|
258
276
|
}
|
|
259
277
|
|
|
260
278
|
// The bare-hour words for a minute-0 duration confinement, joined and followed
|
|
@@ -381,9 +399,15 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
|
|
|
381
399
|
'minute', 'hour', opts);
|
|
382
400
|
|
|
383
401
|
if (plan.hours.kind === 'during') {
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
402
|
+
// A uneven hour stride confines the minute cadence to its own bounded hour
|
|
403
|
+
// cadence ("every 15 minutes, every five hours from midnight through 8
|
|
404
|
+
// p.m."); an irregular hour list still names each hour's window.
|
|
405
|
+
const cadence = unevenHourCadence(ir, opts);
|
|
406
|
+
|
|
407
|
+
phrase += cadence ?
|
|
408
|
+
', ' + cadence :
|
|
409
|
+
' during the ' +
|
|
410
|
+
hourTimesFromPlan(ir, plan.hours.times, false, opts) + ' hours';
|
|
387
411
|
}
|
|
388
412
|
else if (plan.hours.kind === 'window') {
|
|
389
413
|
phrase += ' ' + hourWindow(plan.hours, opts);
|
|
@@ -422,13 +446,20 @@ function renderMinuteSpanInHour(ir: IR, plan: PlanOf<'minuteSpanInHour'>,
|
|
|
422
446
|
// during each hour.
|
|
423
447
|
function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
|
|
424
448
|
opts: NormalizedOptions): string {
|
|
449
|
+
// A uneven hour stride reads as a cadence, not a wall of hour columns: the
|
|
450
|
+
// minute lead, then "every N hours from X through Y".
|
|
451
|
+
const cadence = unevenHourCadence(ir, opts);
|
|
452
|
+
|
|
425
453
|
if (plan.form === 'wildcard') {
|
|
454
|
+
if (cadence !== null) {
|
|
455
|
+
return 'every minute, ' + cadence + trailingQualifier(ir, opts);
|
|
456
|
+
}
|
|
457
|
+
|
|
426
458
|
return 'every minute during the ' +
|
|
427
459
|
hourTimesFromPlan(ir, plan.times, false, opts) + ' hours' +
|
|
428
460
|
trailingQualifier(ir, opts);
|
|
429
461
|
}
|
|
430
462
|
|
|
431
|
-
const times = hourTimesFromPlan(ir, plan.times, true, opts);
|
|
432
463
|
const lead = plan.form === 'range' ?
|
|
433
464
|
minuteRangeLead(ir.pattern.minute, opts) :
|
|
434
465
|
// The 'list' form is a minute list, which has segments; an offset/uneven
|
|
@@ -437,6 +468,12 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
|
|
|
437
468
|
listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
|
|
438
469
|
'minute', 'hour', opts);
|
|
439
470
|
|
|
471
|
+
if (cadence !== null) {
|
|
472
|
+
return lead + ', ' + cadence + trailingQualifier(ir, opts);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const times = hourTimesFromPlan(ir, plan.times, true, opts);
|
|
476
|
+
|
|
440
477
|
return lead + ', at ' + times + trailingQualifier(ir, opts);
|
|
441
478
|
}
|
|
442
479
|
|
|
@@ -466,6 +503,9 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
|
|
|
466
503
|
// segment is a step segment.
|
|
467
504
|
const segment = ir.analyses.segments.hour![0] as StepSegment;
|
|
468
505
|
|
|
506
|
+
// A wildcard minute over a stepped hour is reached only for a clean stride
|
|
507
|
+
// (a bounded or uneven step routes through minutesAcrossHours instead), so it
|
|
508
|
+
// confines to every Nth hour without a bounded-cadence case here.
|
|
469
509
|
if (plan.form === 'wildcard') {
|
|
470
510
|
return 'every minute ' + everyNthHour(segment, opts) +
|
|
471
511
|
trailingQualifier(ir, opts);
|
|
@@ -478,8 +518,13 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
|
|
|
478
518
|
listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
|
|
479
519
|
'minute', 'hour', opts) :
|
|
480
520
|
minuteRangeLead(ir.pattern.minute, opts);
|
|
521
|
+
// A bounded or uneven hour step reads as its endpoint-pinning cadence after
|
|
522
|
+
// the minute lead, not a wall of clock-time columns; an offset-clean step
|
|
523
|
+
// keeps its existing per-step phrasing.
|
|
524
|
+
const cadence = unevenHourCadence(ir, opts);
|
|
481
525
|
|
|
482
|
-
return lead + ', ' +
|
|
526
|
+
return lead + ', ' +
|
|
527
|
+
(cadence ?? stepHours(segment, opts)) + trailingQualifier(ir, opts);
|
|
483
528
|
}
|
|
484
529
|
|
|
485
530
|
// Lead phrase for a plain minute range: "every minute from <a> through <b>
|
|
@@ -536,18 +581,32 @@ function rangeMinuteLead(ir: IR, opts: NormalizedOptions): string {
|
|
|
536
581
|
|
|
537
582
|
function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
|
|
538
583
|
opts: NormalizedOptions): string {
|
|
584
|
+
// A bounded or uneven hour step reads as its endpoint-pinning cadence ("every
|
|
585
|
+
// two hours from 9 a.m. through 5 p.m."), the same form the compound paths
|
|
586
|
+
// speak; an offset-clean step keeps its bare or "from M" cadence.
|
|
587
|
+
const cadence = unevenHourCadence(ir, opts);
|
|
588
|
+
|
|
589
|
+
if (cadence !== null) {
|
|
590
|
+
return cadence + trailingQualifier(ir, opts);
|
|
591
|
+
}
|
|
592
|
+
|
|
539
593
|
// An hour-step plan is selected only for a stepped hour field, whose
|
|
540
594
|
// first segment is a step segment.
|
|
541
595
|
return stepHours(ir.analyses.segments.hour![0] as StepSegment, opts) +
|
|
542
596
|
trailingQualifier(ir, opts);
|
|
543
597
|
}
|
|
544
598
|
|
|
545
|
-
// The hour-range plan as a window
|
|
546
|
-
//
|
|
547
|
-
//
|
|
599
|
+
// The hour-range plan as a window. The close lands on the top of the final
|
|
600
|
+
// hour (`:00`) unless the minute genuinely runs to the end of that hour — i.e.
|
|
601
|
+
// a wildcard minute, which fills every minute and states no separate clause.
|
|
602
|
+
// A pinned/listed/ranged minute is named in its own lead clause, so folding it
|
|
603
|
+
// into the close too would read as a span ("through 5:05 p.m.") that
|
|
604
|
+
// contradicts the minute clause; the window stays bare ("through 5 p.m.").
|
|
548
605
|
function boundedWindow(plan: PlanOf<'hourRange'>):
|
|
549
606
|
{from: number; to: number; last: number} {
|
|
550
|
-
|
|
607
|
+
const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
|
|
608
|
+
|
|
609
|
+
return {from: plan.from, last, to: plan.to};
|
|
551
610
|
}
|
|
552
611
|
|
|
553
612
|
// An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m.". Windows
|
|
@@ -563,10 +622,13 @@ function hourWindow(window: {from: number; to: number; last: number},
|
|
|
563
622
|
// a day-level qualifier, e.g. "every day at 9 a.m. and 9:30 a.m.".
|
|
564
623
|
function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
565
624
|
opts: NormalizedOptions): string {
|
|
566
|
-
// An hour step (or arithmetic-progression hour list) under a
|
|
567
|
-
// minute reads as a cadence rather than a
|
|
625
|
+
// An hour step or range (or arithmetic-progression hour list) under a
|
|
626
|
+
// single pinned minute reads as a cadence or window rather than a
|
|
627
|
+
// cross-product of clock times.
|
|
568
628
|
if (ir.shapes.minute === 'single') {
|
|
569
|
-
const
|
|
629
|
+
const minute = +ir.pattern.minute;
|
|
630
|
+
const cadence = hourCadence(ir, minute, opts) ??
|
|
631
|
+
hourRangeCadence(ir, minute, opts);
|
|
570
632
|
|
|
571
633
|
if (cadence !== null) {
|
|
572
634
|
return cadence;
|
|
@@ -592,10 +654,11 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
|
592
654
|
function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
593
655
|
opts: NormalizedOptions): string {
|
|
594
656
|
if (plan.fold) {
|
|
595
|
-
// An hour step (or arithmetic-progression hour list) under the
|
|
596
|
-
// pinned minute reads as a cadence, not a wall of clock
|
|
597
|
-
// null for an irregular list
|
|
598
|
-
const cadence = hourCadence(ir, +plan.minute, opts)
|
|
657
|
+
// An hour step or range (or arithmetic-progression hour list) under the
|
|
658
|
+
// single pinned minute reads as a cadence or window, not a wall of clock
|
|
659
|
+
// times. (Returns null for an irregular list, which keeps folding below.)
|
|
660
|
+
const cadence = hourCadence(ir, +plan.minute, opts) ??
|
|
661
|
+
hourRangeCadence(ir, +plan.minute, opts);
|
|
599
662
|
|
|
600
663
|
if (cadence !== null) {
|
|
601
664
|
return cadence;
|
|
@@ -619,12 +682,18 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
|
619
682
|
hourSegmentTimes(ir, fold, true, opts);
|
|
620
683
|
}
|
|
621
684
|
|
|
622
|
-
const
|
|
685
|
+
const minuteLead =
|
|
623
686
|
// The non-fold branch is a minute list, which has segments. An
|
|
624
687
|
// offset/uneven step enumerated to that list reads as a stride.
|
|
625
|
-
|
|
688
|
+
strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
|
|
626
689
|
listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
|
|
627
|
-
'minute', 'hour', opts)
|
|
690
|
+
'minute', 'hour', opts);
|
|
691
|
+
// A uneven hour stride reads as a cadence after the minute lead, not a wall
|
|
692
|
+
// of clock-time columns.
|
|
693
|
+
const cadence = unevenHourCadence(ir, opts);
|
|
694
|
+
const phrase = cadence ?
|
|
695
|
+
minuteLead + ', ' + cadence + trailingQualifier(ir, opts) :
|
|
696
|
+
minuteLead +
|
|
628
697
|
', at ' + hourSegmentTimes(ir, {minute: 0, second: null}, true, opts) +
|
|
629
698
|
trailingQualifier(ir, opts);
|
|
630
699
|
|
|
@@ -847,12 +916,74 @@ function hourStrideCadence(stride: {start: number; interval: number;
|
|
|
847
916
|
through(opts) + getTime({hour: last, minute: 0}, opts);
|
|
848
917
|
}
|
|
849
918
|
|
|
919
|
+
// Whether an hour stride wraps the day cleanly from within its first interval
|
|
920
|
+
// (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
|
|
921
|
+
// stride has no distinct endpoint and keeps its bare or "from M" cadence. Every
|
|
922
|
+
// other stride — a uneven interval, or one starting at or past its interval
|
|
923
|
+
// (a bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
|
|
924
|
+
function offsetCleanStride(
|
|
925
|
+
stride: {start: number; interval: number}
|
|
926
|
+
): boolean {
|
|
927
|
+
return stride.start < stride.interval && 24 % stride.interval === 0;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// The bounded cadence for an hour stride that pins both clock-time endpoints,
|
|
931
|
+
// or null when the hour is not such a stride. The core rewrites a uneven step
|
|
932
|
+
// to its fire list, so a minute window/list/step crossed with it lands in the
|
|
933
|
+
// enumerating list paths; there the bounded hour reads better as its cadence
|
|
934
|
+
// ("…, every five hours from midnight through 8 p.m.") than as a wall of
|
|
935
|
+
// clock-time columns. An offset-clean stride keeps its existing confinement
|
|
936
|
+
// form, so only the endpoint-bearing case routes here.
|
|
937
|
+
function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
|
|
938
|
+
const stride = hourStride(ir);
|
|
939
|
+
|
|
940
|
+
if (!stride || offsetCleanStride(stride)) {
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return hourStrideCadence(stride, opts);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// An hour list's arithmetic progression, or null when its values are not a
|
|
948
|
+
// step the renderer should speak as a cadence. The core rewrites a uneven hour
|
|
949
|
+
// step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
|
|
950
|
+
// literal fire list, indistinguishable in the IR from a hand-written list; the
|
|
951
|
+
// renderer recovers the cadence from the values. A progression starting at
|
|
952
|
+
// zero is a `*/n` step however short (0,7,14,21 is `*/7`); a non-zero one is
|
|
953
|
+
// only a step when it is too long to be a deliberate clock-time list (e.g.
|
|
954
|
+
// 9,17 is two named times, not a cadence), the same length the minute/second
|
|
955
|
+
// list path uses. Interval one is a plain range, never a step.
|
|
956
|
+
function hourListStride(values: number[]):
|
|
957
|
+
{start: number; interval: number; last: number} | null {
|
|
958
|
+
if (values.length < 2) {
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const interval = values[1] - values[0];
|
|
963
|
+
|
|
964
|
+
if (interval < 2) {
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
for (let i = 2; i < values.length; i += 1) {
|
|
969
|
+
if (values[i] - values[i - 1] !== interval) {
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (values[0] !== 0 && values.length < 5) {
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return {interval, last: values[values.length - 1], start: values[0]};
|
|
979
|
+
}
|
|
980
|
+
|
|
850
981
|
// The hour field's stride, or null when the hour is not a cadence: a step
|
|
851
982
|
// segment yields its {start, interval, last} directly; an all-single hour
|
|
852
|
-
// list yields one only when its values form a
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
983
|
+
// list yields one only when its values form a step progression (so an irregular
|
|
984
|
+
// list like 9,17 keeps enumerating). The IR is unchanged — the renderer
|
|
985
|
+
// recognizes the stride and speaks it as a cadence instead of the clock-time
|
|
986
|
+
// cross-product.
|
|
856
987
|
function hourStride(ir: IR):
|
|
857
988
|
{start: number; interval: number; last: number} | null {
|
|
858
989
|
// Reached only from the clock-time paths, which run under discrete hours
|
|
@@ -861,6 +992,13 @@ function hourStride(ir: IR):
|
|
|
861
992
|
|
|
862
993
|
if (segments.length === 1 && segments[0].kind === 'step') {
|
|
863
994
|
const segment = segments[0];
|
|
995
|
+
|
|
996
|
+
// A bounded step that fires only once (e.g. `9-10/5` → just 9) is a single
|
|
997
|
+
// value, not a stride: it has no interval to speak and no endpoint to pin.
|
|
998
|
+
if (segment.fires.length < 2) {
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
864
1002
|
const start = segment.startToken === '*' ?
|
|
865
1003
|
0 :
|
|
866
1004
|
+segment.startToken.split('-')[0];
|
|
@@ -870,9 +1008,8 @@ function hourStride(ir: IR):
|
|
|
870
1008
|
}
|
|
871
1009
|
|
|
872
1010
|
const values = singleValues(segments);
|
|
873
|
-
const step = values && arithmeticStep(values);
|
|
874
1011
|
|
|
875
|
-
return
|
|
1012
|
+
return values && hourListStride(values);
|
|
876
1013
|
}
|
|
877
1014
|
|
|
878
1015
|
// The second's status against a pinned minute: a wildcard or sub-minute step
|
|
@@ -931,7 +1068,13 @@ function hourCadence(ir: IR, minute: number,
|
|
|
931
1068
|
|
|
932
1069
|
const fires = (stride.last - stride.start) / stride.interval + 1;
|
|
933
1070
|
|
|
934
|
-
|
|
1071
|
+
// A short stride that spells out as few clock times stays an enumeration only
|
|
1072
|
+
// when it wraps cleanly (an offset-clean stride with no endpoint): the bare
|
|
1073
|
+
// or "from M" form is no shorter than the list, so the list reads fine. A
|
|
1074
|
+
// bounded or uneven stride has no clean wrap, so its endpoint-pinning cadence
|
|
1075
|
+
// ("every five hours from midnight through 8 p.m.") reads better however few.
|
|
1076
|
+
if (ir.pattern.second === '0' && fires <= maxClockTimes &&
|
|
1077
|
+
offsetCleanStride(stride)) {
|
|
935
1078
|
return null;
|
|
936
1079
|
}
|
|
937
1080
|
|
|
@@ -948,6 +1091,14 @@ function hourCadence(ir: IR, minute: number,
|
|
|
948
1091
|
everyNthHour(confinement, opts) + trailingQualifier(ir, opts);
|
|
949
1092
|
}
|
|
950
1093
|
|
|
1094
|
+
// A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
|
|
1095
|
+
// lead clause to fold in, so the bounded cadence stands on its own ("every
|
|
1096
|
+
// five hours from midnight through 8 p.m."); only a real minute or second
|
|
1097
|
+
// prefixes its clause.
|
|
1098
|
+
if (minute === 0 && ir.pattern.second === '0') {
|
|
1099
|
+
return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
951
1102
|
return hourCadenceLead(ir, minute, opts) + ', ' +
|
|
952
1103
|
hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
|
|
953
1104
|
}
|
|
@@ -970,6 +1121,94 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
|
|
|
970
1121
|
return segment;
|
|
971
1122
|
}
|
|
972
1123
|
|
|
1124
|
+
// Whether the hour field is a range — or a list whose segments include a
|
|
1125
|
+
// range — and so forms a window rather than a cross-product of clock times.
|
|
1126
|
+
// A pure single-value list (9,17) has no range to span and still enumerates;
|
|
1127
|
+
// a step is handled by hourStride/hourCadence, so a field whose only segments
|
|
1128
|
+
// are steps and singles is left alone here.
|
|
1129
|
+
function hasHourWindow(ir: IR): boolean {
|
|
1130
|
+
// Reached only from the clock-time paths, which run under discrete hours
|
|
1131
|
+
// and so always carry hour segments.
|
|
1132
|
+
return ir.analyses.segments.hour!.some(function range(segment) {
|
|
1133
|
+
return segment.kind === 'range';
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// The hour-range window as a cadence tail at the top of each hour: each range
|
|
1138
|
+
// segment is a "from X through Y" window ("every hour from 9 a.m. through
|
|
1139
|
+
// 5 p.m."), and any non-contiguous single hour is appended ("and at 10 p.m.").
|
|
1140
|
+
// The minute has already folded into the lead, so the window closes on the
|
|
1141
|
+
// top of its final hour. Mirrors foldedHourWindows but pinned to minute 0.
|
|
1142
|
+
function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
|
|
1143
|
+
const windows: string[] = [];
|
|
1144
|
+
const singles: number[] = [];
|
|
1145
|
+
|
|
1146
|
+
// Reached only after hasHourWindow, so hour segments exist.
|
|
1147
|
+
ir.analyses.segments.hour!.forEach(function classify(segment) {
|
|
1148
|
+
if (segment.kind === 'range') {
|
|
1149
|
+
windows.push('from ' + getTime({hour: +segment.bounds[0], minute: 0},
|
|
1150
|
+
opts) + through(opts) +
|
|
1151
|
+
getTime({hour: +segment.bounds[1], minute: 0}, opts));
|
|
1152
|
+
}
|
|
1153
|
+
else if (segment.kind === 'step') {
|
|
1154
|
+
singles.push(...segment.fires);
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
singles.push(+segment.value);
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
let phrase = 'every hour ' + joinList(windows, opts);
|
|
1162
|
+
|
|
1163
|
+
if (singles.length) {
|
|
1164
|
+
phrase += ' and at ' + joinList(singles.map(function time(hour) {
|
|
1165
|
+
return getTime({hour, minute: 0}, opts);
|
|
1166
|
+
}), opts);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
return phrase;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Render an hour range (or a list whose segments include a range) under a
|
|
1173
|
+
// single pinned minute and a second as the hour-range window — the lead
|
|
1174
|
+
// clause, then "every hour from X through Y" — instead of cross-multiplying
|
|
1175
|
+
// the hours into a wall of clock times. Returns null when the hour has no
|
|
1176
|
+
// range (a pure single-value list, a single hour, or a step, which other
|
|
1177
|
+
// paths own), or when a plain :00 set is short enough that enumeration is no
|
|
1178
|
+
// longer than the window. Renderer-only; the IR is unchanged.
|
|
1179
|
+
function hourRangeCadence(ir: IR, minute: number,
|
|
1180
|
+
opts: NormalizedOptions): string | null {
|
|
1181
|
+
// Scoped to minute 0: the minute folds into the lead and every hour fires
|
|
1182
|
+
// at the top, so the window closes cleanly on the final hour. A non-zero
|
|
1183
|
+
// pinned minute is a real clock minute the existing clock-time window form
|
|
1184
|
+
// already speaks ("9:30:15 a.m. through 8:30:15 p.m."), unchanged.
|
|
1185
|
+
if (minute !== 0 || !hasHourWindow(ir)) {
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// A plain top-of-minute second (:00) carries no clause: the existing
|
|
1190
|
+
// hour-range and folded-window renderers already speak that window, so this
|
|
1191
|
+
// path only forms a window when there is a meaningful second to lead with.
|
|
1192
|
+
if (ir.pattern.second === '0') {
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// A wildcard or sub-minute step second confined to minute 0 is the whole
|
|
1197
|
+
// minute-0 window ("every second for one minute"), confined to the hour
|
|
1198
|
+
// range with the "during the … hours" idiom (the same idiom an hour list
|
|
1199
|
+
// uses). This is kept distinct from the bare minute-0 window ("every hour
|
|
1200
|
+
// from 9 a.m. through 5 p.m.") so the one-minute confinement is never heard
|
|
1201
|
+
// as it — the hour-range analog of "for one minute during every other hour".
|
|
1202
|
+
if (subMinuteSecond(ir)) {
|
|
1203
|
+
return secondsClause(ir, 'minute', opts) + ' for one minute during the ' +
|
|
1204
|
+
hourSegmentTimes(ir, {minute: 0, second: null}, false, opts) +
|
|
1205
|
+
' hours' + trailingQualifier(ir, opts);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
return hourCadenceLead(ir, minute, opts) + ', ' +
|
|
1209
|
+
hourRangeWindowTail(ir, opts) + trailingQualifier(ir, opts);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
973
1212
|
// --- List and segment phrasing. ---
|
|
974
1213
|
|
|
975
1214
|
// Chicago number style for a series: if any value crosses the spell-out
|
|
@@ -1265,29 +1504,51 @@ function monthFoldsIntoDate(ir: IR): boolean {
|
|
|
1265
1504
|
|
|
1266
1505
|
// Compose the "day-of-month or day-of-week" phrase used when both fields
|
|
1267
1506
|
// are restricted: cron fires when either is a match. A restricted month
|
|
1268
|
-
// scopes
|
|
1507
|
+
// scopes BOTH halves, so it attaches to the whole or, never to a single
|
|
1508
|
+
// branch. When the month folds into a calendar date ("on June 13") it also
|
|
1509
|
+
// names itself on the weekday ("or on Friday in June"), keeping both halves
|
|
1510
|
+
// scoped; otherwise (a Quartz date, an open day step, a month range, or the
|
|
1511
|
+
// odd/even frequency) it trails the whole or as ", in <month>".
|
|
1269
1512
|
function dateOrWeekday(ir: IR, opts: NormalizedOptions): string {
|
|
1270
1513
|
const pattern = ir.pattern;
|
|
1271
1514
|
const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) ||
|
|
1272
1515
|
'on ' + weekdayPhrase(ir, opts);
|
|
1516
|
+
|
|
1517
|
+
if (pattern.month !== '*' && monthFoldsIntoDate(ir) &&
|
|
1518
|
+
!quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
|
|
1519
|
+
return 'on ' + monthDatePhrase(ir, opts) + ' or ' + weekdayPart +
|
|
1520
|
+
' in ' + monthName(ir, opts);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
return datePart(ir, opts) + ' or ' + weekdayPart + orMonthScope(ir, opts);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// The day-of-month half of an or-day phrase, without any month scope (the
|
|
1527
|
+
// month scopes the whole or, applied by the caller).
|
|
1528
|
+
function datePart(ir: IR, opts: NormalizedOptions): string {
|
|
1529
|
+
const pattern = ir.pattern;
|
|
1273
1530
|
const quartzDate = quartzDatePhrase(pattern.date, opts);
|
|
1274
1531
|
|
|
1275
1532
|
if (quartzDate) {
|
|
1276
|
-
return quartzDate
|
|
1533
|
+
return quartzDate;
|
|
1277
1534
|
}
|
|
1278
1535
|
|
|
1279
1536
|
if (isOpenStep(pattern.date)) {
|
|
1280
|
-
return stepDates(pattern.date)
|
|
1281
|
-
weekdayPart;
|
|
1537
|
+
return stepDates(pattern.date);
|
|
1282
1538
|
}
|
|
1283
1539
|
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1540
|
+
return 'on the ' + dateOrdinals(ir, opts);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// A trailing month scope for the whole or, set off by a comma so it reads
|
|
1544
|
+
// over both day halves ("…or on Friday, in June"); empty when the month is a
|
|
1545
|
+
// wildcard.
|
|
1546
|
+
function orMonthScope(ir: IR, opts: NormalizedOptions): string {
|
|
1547
|
+
if (ir.pattern.month === '*') {
|
|
1548
|
+
return '';
|
|
1287
1549
|
}
|
|
1288
1550
|
|
|
1289
|
-
return '
|
|
1290
|
-
monthScope(ir, opts);
|
|
1551
|
+
return ', in ' + monthName(ir, opts);
|
|
1291
1552
|
}
|
|
1292
1553
|
|
|
1293
1554
|
// The day-qualifier phrase for a Quartz date field (e.g. "on the last day
|
|
@@ -1431,8 +1692,12 @@ function oddEvenMonth(monthField: string): string | null {
|
|
|
1431
1692
|
// Render the weekday field as names. Ranges read in their connective form
|
|
1432
1693
|
// ("Monday through Friday", or "Mon-Fri" with `short`).
|
|
1433
1694
|
function weekdayPhrase(ir: IR, opts: NormalizedOptions): string {
|
|
1434
|
-
// Reached only with a restricted weekday, which has segments.
|
|
1435
|
-
|
|
1695
|
+
// Reached only with a restricted weekday, which has segments. Weekday lists
|
|
1696
|
+
// display Monday-first (Sunday last) so a weekend reads naturally; the IR
|
|
1697
|
+
// stays canonical (Sunday=0) and ranges keep their form.
|
|
1698
|
+
const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday!);
|
|
1699
|
+
|
|
1700
|
+
return renderSegments(segments, function name(value) {
|
|
1436
1701
|
return getWeekday(value, opts);
|
|
1437
1702
|
}, opts);
|
|
1438
1703
|
}
|