cronli5 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +6 -6
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +401 -81
- package/dist/cronli5.js +401 -81
- package/dist/lang/de.cjs +63 -17
- package/dist/lang/de.js +63 -17
- package/dist/lang/en.cjs +401 -81
- package/dist/lang/en.js +401 -81
- package/dist/lang/es.cjs +68 -5
- package/dist/lang/es.js +68 -5
- package/dist/lang/fi.cjs +21 -1
- package/dist/lang/fi.js +21 -1
- package/dist/lang/zh.cjs +38 -7
- package/dist/lang/zh.js +38 -7
- package/package.json +1 -1
- package/src/core/ir.ts +5 -0
- package/src/core/util.ts +52 -1
- package/src/lang/de/index.ts +95 -25
- package/src/lang/en/dialects.ts +6 -2
- package/src/lang/en/index.ts +781 -117
- package/src/lang/es/index.ts +85 -9
- package/src/lang/fi/index.ts +6 -2
- package/src/lang/zh/index.ts +44 -18
- package/types/core/ir.d.ts +1 -0
- package/types/core/util.d.ts +10 -1
package/src/lang/en/index.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
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
|
-
import {clockDigits, numeral} from '../../core/format.js';
|
|
8
|
+
import {clockDigits, numeral, pad} from '../../core/format.js';
|
|
9
9
|
import type {Cronli5Options} from '../../types.js';
|
|
10
10
|
import type {
|
|
11
11
|
HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
|
|
@@ -130,7 +130,16 @@ function normalizeOptions(options?: Cronli5Options): NormalizedOptions {
|
|
|
130
130
|
|
|
131
131
|
// Render an analyzed cron pattern (the IR) as English.
|
|
132
132
|
function describe(ir: IR, opts: NormalizedOptions): string {
|
|
133
|
-
|
|
133
|
+
// A finer leading cadence puts each coarser field in the confinement frame,
|
|
134
|
+
// overriding the per-plan juxtaposed-cadence and duration-frame forms.
|
|
135
|
+
const body = confinement(ir, opts) ?? render(ir, ir.plan, opts);
|
|
136
|
+
|
|
137
|
+
// A day union scopes the whole clause by its month, which leads the
|
|
138
|
+
// description ("in June <time> whenever the day is …"); the time/cadence and
|
|
139
|
+
// the trailing condition are already in `body`.
|
|
140
|
+
const lead = isDayUnion(ir, opts) ? dayUnionMonthLead(ir, opts) : '';
|
|
141
|
+
|
|
142
|
+
return applyYear(lead + body, ir, opts);
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
// Render one plan node. `composeSeconds` recurses with its `rest` plan.
|
|
@@ -337,7 +346,7 @@ function secondsClause(ir: IR, anchor: string,
|
|
|
337
346
|
|
|
338
347
|
if (shape === 'range') {
|
|
339
348
|
const bounds = secondField.split('-');
|
|
340
|
-
const num = seriesNumber(
|
|
349
|
+
const num = seriesNumber();
|
|
341
350
|
|
|
342
351
|
return 'every second from ' + num(bounds[0]) +
|
|
343
352
|
through(opts) + num(bounds[1]) + ' past the ' + anchor;
|
|
@@ -460,10 +469,34 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
|
|
|
460
469
|
trailingQualifier(ir, opts);
|
|
461
470
|
}
|
|
462
471
|
|
|
463
|
-
|
|
464
|
-
minuteRangeLead(ir.pattern.minute, opts)
|
|
465
|
-
|
|
466
|
-
|
|
472
|
+
if (plan.form === 'range') {
|
|
473
|
+
const lead = minuteRangeLead(ir.pattern.minute, opts);
|
|
474
|
+
|
|
475
|
+
if (cadence !== null) {
|
|
476
|
+
return lead + ', ' + cadence + trailingQualifier(ir, opts);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// A plain minute range is a cadence, so an hour list confines it with the
|
|
480
|
+
// "during the … hours" idiom — the same reading the seconds-leading
|
|
481
|
+
// sibling and the wildcard-minute form already use — rather than a
|
|
482
|
+
// clock-time "at <times>" list, which reads as discrete fire points. A
|
|
483
|
+
// lone hour is not a list, so it keeps the "at <time>" frame ("…past the
|
|
484
|
+
// hour, at 9 a.m."), never the plural "hours" confinement.
|
|
485
|
+
if (singleHourFire(plan.times)) {
|
|
486
|
+
return lead + ', at ' +
|
|
487
|
+
hourTimesFromPlan(ir, plan.times, true, opts) +
|
|
488
|
+
trailingQualifier(ir, opts);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return lead + ' during the ' +
|
|
492
|
+
hourTimesFromPlan(ir, plan.times, false, opts) + ' hours' +
|
|
493
|
+
trailingQualifier(ir, opts);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// The 'list' form is a minute list, which has segments; an offset/uneven
|
|
497
|
+
// step enumerated to that list reads as a stride. A list is a set of
|
|
498
|
+
// discrete fire minutes, not a cadence, so it keeps the "at <times>" frame.
|
|
499
|
+
const lead =
|
|
467
500
|
strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
|
|
468
501
|
listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
|
|
469
502
|
'minute', 'hour', opts);
|
|
@@ -532,7 +565,7 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
|
|
|
532
565
|
function minuteRangeLead(minuteField: string,
|
|
533
566
|
opts: NormalizedOptions): string {
|
|
534
567
|
const bounds = minuteField.split('-');
|
|
535
|
-
const num = seriesNumber(
|
|
568
|
+
const num = seriesNumber();
|
|
536
569
|
|
|
537
570
|
return 'every minute from ' + num(bounds[0]) + through(opts) +
|
|
538
571
|
num(bounds[1]) + ' past the hour';
|
|
@@ -596,21 +629,48 @@ function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
|
|
|
596
629
|
trailingQualifier(ir, opts);
|
|
597
630
|
}
|
|
598
631
|
|
|
599
|
-
// The hour-range plan as a window
|
|
600
|
-
//
|
|
601
|
-
//
|
|
632
|
+
// The hour-range plan as a window. The close lands on the top of the final
|
|
633
|
+
// hour (`:00`) unless the minute genuinely runs to the end of that hour — i.e.
|
|
634
|
+
// a wildcard minute, which fills every minute and states no separate clause.
|
|
635
|
+
// A pinned/listed/ranged minute is named in its own lead clause, so folding it
|
|
636
|
+
// 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.").
|
|
602
638
|
function boundedWindow(plan: PlanOf<'hourRange'>):
|
|
603
639
|
{from: number; to: number; last: number} {
|
|
604
|
-
|
|
640
|
+
const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
|
|
641
|
+
|
|
642
|
+
return {from: plan.from, last, to: plan.to};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// A contiguous hour range as a window phrase. The default English dialect
|
|
646
|
+
// reads a MULTI-hour range as an up-to-but-not-including window — "from 9 a.m.
|
|
647
|
+
// until 6 p.m." (the close is the top of the hour after the last, the sense
|
|
648
|
+
// English uses for time windows: 9-17 runs until 6 p.m.); 23 wraps to
|
|
649
|
+
// midnight. Every other dialect (and the compact `short` form) keeps the
|
|
650
|
+
// "through <last fire>" span, closing on the minute field's last fire within
|
|
651
|
+
// the final hour. A single-hour sub-hour window (`from === to`, e.g. */15 9
|
|
652
|
+
// firing 9:00 through 9:45) is NOT a multi-hour range: its close is a real
|
|
653
|
+
// fire inside the hour, so it always keeps "through" — naming "until 10 a.m."
|
|
654
|
+
// would overstate the span past the last fire.
|
|
655
|
+
function rangeWindow(from: number, to: number, throughMinute: number | string,
|
|
656
|
+
opts: NormalizedOptions): string {
|
|
657
|
+
const open = 'from ' + getTime({hour: from, minute: 0}, opts);
|
|
658
|
+
|
|
659
|
+
if (opts.style.untilWindow && !opts.short && from !== to) {
|
|
660
|
+
return open + ' until ' +
|
|
661
|
+
getTime({hour: (to + 1) % 24, minute: 0}, opts);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return open + through(opts) +
|
|
665
|
+
getTime({hour: to, minute: throughMinute}, opts);
|
|
605
666
|
}
|
|
606
667
|
|
|
607
|
-
// An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m.".
|
|
608
|
-
//
|
|
609
|
-
// fire within the final hour.
|
|
668
|
+
// 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). Windows open at the top of the first
|
|
670
|
+
// hour and close at the minute field's last fire within the final hour.
|
|
610
671
|
function hourWindow(window: {from: number; to: number; last: number},
|
|
611
672
|
opts: NormalizedOptions): string {
|
|
612
|
-
return
|
|
613
|
-
through(opts) + getTime({hour: window.to, minute: window.last}, opts);
|
|
673
|
+
return rangeWindow(window.from, window.to, window.last, opts);
|
|
614
674
|
}
|
|
615
675
|
|
|
616
676
|
// Expand a discrete set of hours and minutes into clock times prefixed by
|
|
@@ -640,7 +700,15 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
|
640
700
|
}, opts);
|
|
641
701
|
});
|
|
642
702
|
|
|
643
|
-
return interpretDayQualifier(ir, opts) + 'at ' + joinList(times, opts)
|
|
703
|
+
return interpretDayQualifier(ir, opts) + 'at ' + joinList(times, opts) +
|
|
704
|
+
dayUnionTrail(ir, opts);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// The trailing day-union condition for a clock-time form (which leads with its
|
|
708
|
+
// time, not a day qualifier), or an empty string when the pattern is not a day
|
|
709
|
+
// union. The cadence renderers carry this through `trailingQualifier` instead.
|
|
710
|
+
function dayUnionTrail(ir: IR, opts: NormalizedOptions): string {
|
|
711
|
+
return isDayUnion(ir, opts) ? dayUnionCondition(ir, opts) : '';
|
|
644
712
|
}
|
|
645
713
|
|
|
646
714
|
// Compact form for a clock-time set past the enumeration cap. A single
|
|
@@ -674,7 +742,7 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
|
674
742
|
const fold = {minute: plan.minute, second: ir.analyses.clockSecond};
|
|
675
743
|
|
|
676
744
|
return interpretDayQualifier(ir, opts) + 'at ' +
|
|
677
|
-
hourSegmentTimes(ir, fold, true, opts);
|
|
745
|
+
hourSegmentTimes(ir, fold, true, opts) + dayUnionTrail(ir, opts);
|
|
678
746
|
}
|
|
679
747
|
|
|
680
748
|
const minuteLead =
|
|
@@ -701,39 +769,330 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
|
701
769
|
|
|
702
770
|
// A folded hour field that includes a contiguous range reads with the
|
|
703
771
|
// hour-range frame: a shared minute lead ("every hour" / "at 30 minutes
|
|
704
|
-
// past the hour"), each range as a
|
|
705
|
-
//
|
|
772
|
+
// past the hour"), each range as a window, and any non-contiguous hour
|
|
773
|
+
// appended by `outlierTail` (the default until-window form reads "plus Z";
|
|
774
|
+
// every other dialect keeps "and at Z").
|
|
706
775
|
function foldedHourWindows(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
707
776
|
opts: NormalizedOptions): string {
|
|
708
777
|
const minute = plan.minute;
|
|
709
778
|
const windows: string[] = [];
|
|
710
|
-
const
|
|
779
|
+
const outliers = collectHourOutliers(ir);
|
|
780
|
+
const times = outliers.hours.map(function time(hour) {
|
|
781
|
+
return getTime({hour, minute}, opts);
|
|
782
|
+
});
|
|
711
783
|
|
|
712
784
|
// Reached only via the fold branch under discrete hours, which have
|
|
713
785
|
// segments.
|
|
714
786
|
ir.analyses.segments.hour!.forEach(function classify(segment) {
|
|
715
787
|
if (segment.kind === 'range') {
|
|
716
|
-
windows.push(
|
|
717
|
-
|
|
718
|
-
getTime({hour: segment.bounds[1], minute}, opts));
|
|
788
|
+
windows.push(rangeWindow(+segment.bounds[0], +segment.bounds[1],
|
|
789
|
+
minute, opts));
|
|
719
790
|
}
|
|
720
|
-
|
|
721
|
-
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
const phrase = rangeMinuteLead(ir, opts) + ' ' + joinList(windows, opts);
|
|
794
|
+
|
|
795
|
+
return phrase + outlierTail(times, outliers.pureStrays, opts);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// The hours outside a contiguous run — every non-range segment's values — and
|
|
799
|
+
// whether they are all STRAY single values (no step fires). A step beside a run
|
|
800
|
+
// contributes a whole cadence's worth of fires, not a lone outlier, so the
|
|
801
|
+
// "plus" idiom does not fit and the additive list keeps "and at".
|
|
802
|
+
function collectHourOutliers(ir: IR):
|
|
803
|
+
{hours: number[]; pureStrays: boolean} {
|
|
804
|
+
const hours: number[] = [];
|
|
805
|
+
let pureStrays = true;
|
|
806
|
+
|
|
807
|
+
// Reached only under discrete hours, which carry segments.
|
|
808
|
+
ir.analyses.segments.hour!.forEach(function classify(segment) {
|
|
809
|
+
if (segment.kind === 'step') {
|
|
810
|
+
hours.push(...segment.fires);
|
|
811
|
+
pureStrays = false;
|
|
722
812
|
}
|
|
723
|
-
else {
|
|
724
|
-
|
|
813
|
+
else if (segment.kind !== 'range') {
|
|
814
|
+
hours.push(+segment.value);
|
|
725
815
|
}
|
|
726
816
|
});
|
|
727
817
|
|
|
728
|
-
|
|
818
|
+
return {hours, pureStrays};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Join the outlier hour times that follow a contiguous-run window. When the run
|
|
822
|
+
// rendered as the leading until-window ("from 9 a.m. until 9 p.m.") and the
|
|
823
|
+
// outlier is a single stray value, it reads "plus 10 p.m." — an additive idiom
|
|
824
|
+
// for the one hour that breaks the run. A step beside the run is a full cadence
|
|
825
|
+
// of fires, not a lone outlier, so it keeps the enumerating "and at"; so does
|
|
826
|
+
// every other dialect (and the compact `short` form), which renders the run as
|
|
827
|
+
// a "through <last fire>" span rather than the until-window.
|
|
828
|
+
function outlierTail(times: string[], pureStrays: boolean,
|
|
829
|
+
opts: NormalizedOptions): string {
|
|
830
|
+
if (!times.length) {
|
|
831
|
+
return '';
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const connector = pureStrays && opts.style.untilWindow && !opts.short ?
|
|
835
|
+
' plus ' :
|
|
836
|
+
' and at ';
|
|
837
|
+
|
|
838
|
+
return connector + joinList(times, opts);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// --- Confinement frame. ---
|
|
842
|
+
//
|
|
843
|
+
// Under a finer LEADING CADENCE — the finest restricted field spoken as a
|
|
844
|
+
// recurrence ("every second", "every 15 seconds", "every minute", "every two
|
|
845
|
+
// minutes") — each COARSER restricted field reads as a confinement, not a
|
|
846
|
+
// juxtaposed cadence: "every second during minute :00 of every hour", "every
|
|
847
|
+
// second of the midnight hour", "every two minutes from midnight until 1 a.m.".
|
|
848
|
+
// A redundant unrestricted finer field drops ("every second" already spans all
|
|
849
|
+
// minutes, so a wildcard minute is not stated). The leading field is the
|
|
850
|
+
// seconds when it is a wildcard or clean step; otherwise the minute, when the
|
|
851
|
+
// second is a plain :00 and the minute is a wildcard or clean step. A single,
|
|
852
|
+
// range, or list lead is a clock-point form ("at 30 seconds past the minute"),
|
|
853
|
+
// not a cadence, and is left to the existing renderers.
|
|
854
|
+
|
|
855
|
+
// Whether a field token is a wildcard or a clean step (`*/n`) — the two shapes
|
|
856
|
+
// that read as a leading cadence. A bounded step (`a-b/n`) is a windowed set,
|
|
857
|
+
// not a clean day/hour-spanning cadence.
|
|
858
|
+
function isCadenceField(token: string): boolean {
|
|
859
|
+
return token === '*' ||
|
|
860
|
+
token.startsWith('*/') && token.indexOf('-') === -1;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// The leading cadence and whether the second is the leading field, or null when
|
|
864
|
+
// the pattern has no cadence lead (the finest restricted field is a clock-point
|
|
865
|
+
// single/range/list). The seconds lead when restricted as a cadence; otherwise
|
|
866
|
+
// the minute leads when the second is a plain :00 and the minute is a cadence.
|
|
867
|
+
function leadingCadence(ir: IR, opts: NormalizedOptions):
|
|
868
|
+
{text: string; secondLead: boolean} | null {
|
|
869
|
+
const {second, minute} = ir.pattern;
|
|
870
|
+
|
|
871
|
+
if (isCadenceField(second)) {
|
|
872
|
+
return {secondLead: true, text: secondsClause(ir, 'minute', opts)};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (second === '0' && isCadenceField(minute)) {
|
|
876
|
+
const text = minute === '*' ?
|
|
877
|
+
'every minute' :
|
|
878
|
+
// A clean minute step's first segment is a step segment.
|
|
879
|
+
stepCycle60(ir.analyses.segments.minute![0] as StepSegment,
|
|
880
|
+
'minute', 'hour', opts);
|
|
729
881
|
|
|
730
|
-
|
|
731
|
-
phrase += ' and at ' + joinList(singles.map(function time(hour) {
|
|
732
|
-
return getTime({hour, minute}, opts);
|
|
733
|
-
}), opts);
|
|
882
|
+
return {secondLead: false, text};
|
|
734
883
|
}
|
|
735
884
|
|
|
736
|
-
return
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// A pinned minute (single/range/list) under a seconds lead reads as a
|
|
889
|
+
// confinement: "during minute :NN", "during minutes :NN through :MM", "during
|
|
890
|
+
// minutes :NN and :MM". A clean minute step reads "of every other minute". A
|
|
891
|
+
// wildcard minute is redundant under the seconds cadence and drops (empty).
|
|
892
|
+
function minuteConfinement(ir: IR, opts: NormalizedOptions): string {
|
|
893
|
+
const minute = ir.pattern.minute;
|
|
894
|
+
|
|
895
|
+
if (minute === '*') {
|
|
896
|
+
return '';
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (isCadenceField(minute)) {
|
|
900
|
+
// The gate admits only the `*/2` "every other minute" step here; other
|
|
901
|
+
// minute steps defer to the existing renderer.
|
|
902
|
+
return ' of every other minute';
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// A minute single/range/list under the seconds lead. The minute reads as a
|
|
906
|
+
// ":NN" clock-minute confinement, never "N minutes past the hour" (that is
|
|
907
|
+
// the minute-lead clock-point form).
|
|
908
|
+
const segments = ir.analyses.segments.minute!;
|
|
909
|
+
|
|
910
|
+
if (ir.shapes.minute === 'single') {
|
|
911
|
+
return ' during minute :' + pad(minute);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (ir.shapes.minute === 'range') {
|
|
915
|
+
const bounds = minute.split('-');
|
|
916
|
+
|
|
917
|
+
return ' during minutes :' + pad(bounds[0]) + through(opts) + ':' +
|
|
918
|
+
pad(bounds[1]);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const values = segmentWords(segments, opts).map(function colon(word) {
|
|
922
|
+
return ':' + pad(word);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
return ' during minutes ' + joinList(values, opts);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// A restricted hour under a finer cadence reads as a confinement. The form
|
|
929
|
+
// depends on the nearest stated finer field: a stepped minute makes a single
|
|
930
|
+
// hour a span ("from midnight until 1 a.m."); a pinned minute makes it a clock
|
|
931
|
+
// point ("at midnight"); a wildcard/absent minute makes it the hour itself
|
|
932
|
+
// ("of the midnight hour"). A clean hour step is "of every other hour"; a range
|
|
933
|
+
// reuses the until-window; a list or stepped range reads "during the … hours".
|
|
934
|
+
// A wildcard hour drops (empty).
|
|
935
|
+
function hourConfinement(ir: IR, opts: NormalizedOptions): string {
|
|
936
|
+
const hour = ir.pattern.hour;
|
|
937
|
+
|
|
938
|
+
if (hour === '*') {
|
|
939
|
+
// A pinned minute confinement ("during minute :00") repeats across every
|
|
940
|
+
// hour, so the hour is named as the unit of recurrence; a stepped minute
|
|
941
|
+
// ("of every other minute") or absent minute already implies all hours.
|
|
942
|
+
const minutePinned = ir.pattern.minute !== '*' &&
|
|
943
|
+
!isCadenceField(ir.pattern.minute);
|
|
944
|
+
|
|
945
|
+
return minutePinned ? ' of every hour' : '';
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (isCadenceField(hour)) {
|
|
949
|
+
return hour === '*/2' ? ' of every other hour' : '';
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (ir.shapes.hour === 'single') {
|
|
953
|
+
const h = +hour;
|
|
954
|
+
|
|
955
|
+
if (ir.shapes.minute === 'step') {
|
|
956
|
+
return ' from ' + getTime({hour: h, minute: 0}, opts) + ' until ' +
|
|
957
|
+
getTime({hour: (h + 1) % 24, minute: 0}, opts);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// A pinned minute confinement already named the minute, so the hour reads
|
|
961
|
+
// as a plain clock point; a wildcard or absent minute makes the hour the
|
|
962
|
+
// unit of recurrence ("of the midnight hour").
|
|
963
|
+
if (ir.pattern.minute !== '*' && !isCadenceField(ir.pattern.minute)) {
|
|
964
|
+
return ' at ' + getTime({hour: h, minute: 0}, opts);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
return ' of the ' + getTime({hour: h, minute: 0}, opts) + ' hour';
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (ir.shapes.hour === 'range') {
|
|
971
|
+
const bounds = hour.split('-');
|
|
972
|
+
|
|
973
|
+
return ' ' + rangeWindow(+bounds[0], +bounds[1], 0, opts);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// An hour list or stepped range reads "during the <times> hours".
|
|
977
|
+
return ' during the ' +
|
|
978
|
+
hourSegmentTimes(ir, {minute: 0, second: null}, false, opts) + ' hours';
|
|
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';
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Whether an hour field is confinement-eligible. An OPEN hour stride — a clean
|
|
991
|
+
// `*/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 blessed confinement idiom
|
|
993
|
+
// ("of every other hour"), so other open steps defer. A BOUNDED stepped range
|
|
994
|
+
// (`a-b/n`, e.g. `9-17/2`) is a discrete set of named hours the confinement
|
|
995
|
+
// frame speaks as a list ("during the 9 a.m., 11 a.m., … hours").
|
|
996
|
+
function confinableHour(ir: IR): boolean {
|
|
997
|
+
if (ir.shapes.hour !== 'step') {
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Reached only under a stepped hour, whose first segment is a step segment.
|
|
1002
|
+
const segment = ir.analyses.segments.hour![0] as StepSegment;
|
|
1003
|
+
|
|
1004
|
+
return ir.pattern.hour === '*/2' || segment.startToken.indexOf('-') !== -1;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Whether a minute list is really a stride the existing renderer speaks as a
|
|
1008
|
+
// cadence ("every two minutes from 3 through 59"): such a progression is not a
|
|
1009
|
+
// short explicit ":NN" confinement, so it defers.
|
|
1010
|
+
function isMinuteStride(ir: IR): boolean {
|
|
1011
|
+
if (ir.shapes.minute !== 'list') {
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const values = singleValues(ir.analyses.segments.minute!);
|
|
1016
|
+
|
|
1017
|
+
return values !== null && arithmeticStep(values) !== null;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Whether the pattern is in the panel-blessed confinement shape-set. The frame
|
|
1021
|
+
// covers a finer leading cadence (seconds, or minute under a :00 second) with
|
|
1022
|
+
// each coarser field as a confinement; shapes outside the blessed set defer to
|
|
1023
|
+
// the existing renderers, which already produce the blessed phrasing for them.
|
|
1024
|
+
function confinementEligible(ir: IR,
|
|
1025
|
+
lead: {secondLead: boolean}): boolean {
|
|
1026
|
+
const {minute, hour} = ir.pattern;
|
|
1027
|
+
const minuteStep = isCadenceField(minute) && minute !== '*';
|
|
1028
|
+
|
|
1029
|
+
// A non-`*/2` hour stride keeps the existing cadence form.
|
|
1030
|
+
if (!confinableHour(ir)) {
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (lead.secondLead) {
|
|
1035
|
+
// A minute STEP is blessed only as the `*/2` "every other minute" idiom,
|
|
1036
|
+
// and only where it fills the coarser field: a contiguous hour range or a
|
|
1037
|
+
// single hour both close on the minute's real last fire, which the
|
|
1038
|
+
// windowing renderer already speaks. The `*/2` step fills both, so it keeps
|
|
1039
|
+
// the "of every other minute" confinement; other steps defer entirely.
|
|
1040
|
+
if (minuteStep) {
|
|
1041
|
+
return minute === '*/2' && !isContiguousHourRange(ir);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// A minute list that is really a stride keeps its cadence form; a short
|
|
1045
|
+
// explicit minute list crossed with a discrete hour LIST is a wall of
|
|
1046
|
+
// distinct clock times ("9:00 a.m., 9:25 a.m., …"), not a single minute
|
|
1047
|
+
// confinement. Both stay with the enumerating renderer.
|
|
1048
|
+
if (isMinuteStride(ir) ||
|
|
1049
|
+
ir.shapes.minute === 'list' && ir.shapes.hour === 'list') {
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// A minute-LEAD cadence (second :00). The existing renderers already produce
|
|
1057
|
+
// the blessed phrasing for a single/range/list hour and for a non-`*/2` hour
|
|
1058
|
+
// step; the confinement frame only changes the `*/2` hour ("of every other
|
|
1059
|
+
// hour") and the single hour under an "every other minute" step ("from
|
|
1060
|
+
// midnight until 1 a.m."). Everything else defers.
|
|
1061
|
+
if (hour === '*/2') {
|
|
1062
|
+
return true;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return ir.shapes.hour === 'single' && minute === '*/2';
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Whether the pattern reads with the confinement frame: a finer leading
|
|
1069
|
+
// cadence with each coarser field as a confinement. Routed to from the cadence
|
|
1070
|
+
// renderers in place of the older juxtaposed-cadence and duration-frame forms.
|
|
1071
|
+
function confinement(ir: IR, opts: NormalizedOptions): string | null {
|
|
1072
|
+
// The confinement frame is scoped to the default (US) dialect, the one that
|
|
1073
|
+
// carries the until-window; every other dialect and the compact `short` form
|
|
1074
|
+
// keep their established juxtaposed-cadence / duration-frame phrasing.
|
|
1075
|
+
if (!opts.style.untilWindow || opts.short) {
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// With nothing coarser to confine (minute and hour both wildcard), the bare
|
|
1080
|
+
// cadence renderers already speak the pattern ("every second", "every
|
|
1081
|
+
// minute"); the confinement frame only applies once a coarser field is set.
|
|
1082
|
+
if (ir.pattern.minute === '*' && ir.pattern.hour === '*') {
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const lead = leadingCadence(ir, opts);
|
|
1087
|
+
|
|
1088
|
+
if (!lead || !confinementEligible(ir, lead)) {
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const minutePart = lead.secondLead ? minuteConfinement(ir, opts) : '';
|
|
1093
|
+
|
|
1094
|
+
return lead.text + minutePart + hourConfinement(ir, opts) +
|
|
1095
|
+
trailingQualifier(ir, opts);
|
|
737
1096
|
}
|
|
738
1097
|
|
|
739
1098
|
// The plan dispatch table.
|
|
@@ -785,10 +1144,9 @@ function renderStride(stride: Stride, opts: NormalizedOptions): string {
|
|
|
785
1144
|
pluralize(start, unit) + ' past the ' + anchor;
|
|
786
1145
|
}
|
|
787
1146
|
|
|
788
|
-
// A bounded, non-wrapping set: pin both endpoints.
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
const num = seriesNumber([start, last], opts);
|
|
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();
|
|
792
1150
|
|
|
793
1151
|
return cadence + ' from ' + num(start) + through(opts) + num(last) + ' ' +
|
|
794
1152
|
pluralize(last, unit) + ' past the ' + anchor;
|
|
@@ -1078,12 +1436,12 @@ function hourCadence(ir: IR, minute: number,
|
|
|
1078
1436
|
// minute during every other hour", matching the "every minute during every
|
|
1079
1437
|
// other hour" idiom and keeping it distinct from the bare hour-step form
|
|
1080
1438
|
// ("every two hours") so the minute-0 confinement is never heard as it.
|
|
1081
|
-
const
|
|
1439
|
+
const minuteZeroStride = minute === 0 && subMinuteSecond(ir) &&
|
|
1082
1440
|
cleanStrideSegment(ir);
|
|
1083
1441
|
|
|
1084
|
-
if (
|
|
1442
|
+
if (minuteZeroStride) {
|
|
1085
1443
|
return secondsClause(ir, 'minute', opts) + ' for one minute ' +
|
|
1086
|
-
everyNthHour(
|
|
1444
|
+
everyNthHour(minuteZeroStride, opts) + trailingQualifier(ir, opts);
|
|
1087
1445
|
}
|
|
1088
1446
|
|
|
1089
1447
|
// A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
|
|
@@ -1130,38 +1488,29 @@ function hasHourWindow(ir: IR): boolean {
|
|
|
1130
1488
|
}
|
|
1131
1489
|
|
|
1132
1490
|
// The hour-range window as a cadence tail at the top of each hour: each range
|
|
1133
|
-
// segment is a
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1491
|
+
// segment is a window ("every hour from 9 a.m. until 9 p.m."), and any
|
|
1492
|
+
// non-contiguous single hour is appended by `outlierTail` ("plus 10 p.m." in
|
|
1493
|
+
// the default until-window form, "and at 10 p.m." elsewhere). The minute has
|
|
1494
|
+
// already folded into the lead, so the window closes on the top of its final
|
|
1495
|
+
// hour. Mirrors foldedHourWindows but pinned to minute 0.
|
|
1137
1496
|
function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
|
|
1138
1497
|
const windows: string[] = [];
|
|
1139
|
-
const
|
|
1498
|
+
const outliers = collectHourOutliers(ir);
|
|
1140
1499
|
|
|
1141
1500
|
// Reached only after hasHourWindow, so hour segments exist.
|
|
1142
1501
|
ir.analyses.segments.hour!.forEach(function classify(segment) {
|
|
1143
1502
|
if (segment.kind === 'range') {
|
|
1144
|
-
windows.push(
|
|
1145
|
-
opts)
|
|
1146
|
-
getTime({hour: +segment.bounds[1], minute: 0}, opts));
|
|
1147
|
-
}
|
|
1148
|
-
else if (segment.kind === 'step') {
|
|
1149
|
-
singles.push(...segment.fires);
|
|
1150
|
-
}
|
|
1151
|
-
else {
|
|
1152
|
-
singles.push(+segment.value);
|
|
1503
|
+
windows.push(rangeWindow(+segment.bounds[0], +segment.bounds[1], 0,
|
|
1504
|
+
opts));
|
|
1153
1505
|
}
|
|
1154
1506
|
});
|
|
1155
1507
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
return getTime({hour, minute: 0}, opts);
|
|
1161
|
-
}), opts);
|
|
1162
|
-
}
|
|
1508
|
+
const phrase = 'every hour ' + joinList(windows, opts);
|
|
1509
|
+
const times = outliers.hours.map(function time(hour) {
|
|
1510
|
+
return getTime({hour, minute: 0}, opts);
|
|
1511
|
+
});
|
|
1163
1512
|
|
|
1164
|
-
return phrase;
|
|
1513
|
+
return phrase + outlierTail(times, outliers.pureStrays, opts);
|
|
1165
1514
|
}
|
|
1166
1515
|
|
|
1167
1516
|
// Render an hour range (or a list whose segments include a range) under a
|
|
@@ -1206,41 +1555,55 @@ function hourRangeCadence(ir: IR, minute: number,
|
|
|
1206
1555
|
|
|
1207
1556
|
// --- List and segment phrasing. ---
|
|
1208
1557
|
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
const anyBig = values.some(function big(v) {
|
|
1216
|
-
return +v > 10;
|
|
1217
|
-
});
|
|
1218
|
-
|
|
1558
|
+
// Number style for the bounds of a "from X through Y" series — a range or a
|
|
1559
|
+
// pinned-endpoint stride. The boundary of a range is a clock/calendar VALUE,
|
|
1560
|
+
// not a frequency, so it always reads as a digit ("from 0 through 10", "from 1
|
|
1561
|
+
// through 5"), matching the minutes-/seconds-past convention; only the "every
|
|
1562
|
+
// N" multiplier keeps the spell-when-small style.
|
|
1563
|
+
function seriesNumber(): (n: number | string) => string | number {
|
|
1219
1564
|
return function format(n) {
|
|
1220
|
-
return
|
|
1565
|
+
return '' + n;
|
|
1221
1566
|
};
|
|
1222
1567
|
}
|
|
1223
1568
|
|
|
1224
|
-
//
|
|
1569
|
+
// The number style for an enumerated set of values: a genuine LIST (two or
|
|
1570
|
+
// more comma-separated values) reads as numerals throughout ("at 4, 6, and 9
|
|
1571
|
+
// minutes past the hour") even when every value is small; a lone value keeps
|
|
1572
|
+
// the dialect's spelled-when-small style ("at five minutes past the hour"),
|
|
1573
|
+
// matching the single-value renderers. The list comma is the cue that pushes
|
|
1574
|
+
// the eye to numerals.
|
|
1575
|
+
function listNumber(count: number, opts: NormalizedOptions):
|
|
1576
|
+
(n: number | string) => string | number {
|
|
1577
|
+
return count > 1 ?
|
|
1578
|
+
function asNumeral(n) {
|
|
1579
|
+
return '' + n;
|
|
1580
|
+
} :
|
|
1581
|
+
function spelled(n) {
|
|
1582
|
+
return getNumber(n, opts);
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// Render numeric fire values for an enumerated list: a multi-value list reads
|
|
1587
|
+
// as numerals, a lone value stays spelled (see `listNumber`).
|
|
1225
1588
|
function numberWords(fires: number[],
|
|
1226
1589
|
opts: NormalizedOptions): (string | number)[] {
|
|
1227
|
-
return fires.map(
|
|
1590
|
+
return fires.map(listNumber(fires.length, opts));
|
|
1228
1591
|
}
|
|
1229
1592
|
|
|
1230
|
-
// Render classified segments as words
|
|
1231
|
-
// "<a> through <b>" pairs, step segments as their
|
|
1232
|
-
//
|
|
1593
|
+
// Render classified segments as words for an enumerated list: singles as
|
|
1594
|
+
// numbers, ranges as "<a> through <b>" pairs, step segments as their
|
|
1595
|
+
// enumerated fires. A multi-value list numeralizes throughout; a lone value
|
|
1596
|
+
// keeps the spelled-when-small style (see `listNumber`).
|
|
1233
1597
|
function segmentWords(segments: Segment[],
|
|
1234
1598
|
opts: NormalizedOptions): (string | number)[] {
|
|
1235
|
-
const
|
|
1236
|
-
(string | number)[] {
|
|
1599
|
+
const count = segments.reduce(function tally(sum, segment) {
|
|
1237
1600
|
if (segment.kind === 'range') {
|
|
1238
|
-
return
|
|
1601
|
+
return sum + 1;
|
|
1239
1602
|
}
|
|
1240
1603
|
|
|
1241
|
-
return segment.kind === 'step' ? segment.fires :
|
|
1242
|
-
});
|
|
1243
|
-
const num =
|
|
1604
|
+
return sum + (segment.kind === 'step' ? segment.fires.length : 1);
|
|
1605
|
+
}, 0);
|
|
1606
|
+
const num = listNumber(count, opts);
|
|
1244
1607
|
|
|
1245
1608
|
return segments.flatMap(function word(segment) {
|
|
1246
1609
|
if (segment.kind === 'range') {
|
|
@@ -1293,6 +1656,13 @@ function hourTimes(hours: number[], opts: NormalizedOptions): string {
|
|
|
1293
1656
|
return joinList(times, opts);
|
|
1294
1657
|
}
|
|
1295
1658
|
|
|
1659
|
+
// Whether an hour-times plan names exactly one hour. A lone hour is not a
|
|
1660
|
+
// list, so the cadence renderers keep the "at <time>" frame rather than the
|
|
1661
|
+
// plural "during the … hours" confinement.
|
|
1662
|
+
function singleHourFire(times: HourTimesPlan): boolean {
|
|
1663
|
+
return times.kind === 'fires' && times.fires.length === 1;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1296
1666
|
// The hour times accompanying a window phrase: enumerated fires up to the
|
|
1297
1667
|
// cap, segment rendering past it (decided by the core). `atContext` marks
|
|
1298
1668
|
// an "at <times>" frame (vs "during the <times> hours").
|
|
@@ -1371,22 +1741,35 @@ function disambiguateTimes(pieces: string[], segments: Segment[],
|
|
|
1371
1741
|
});
|
|
1372
1742
|
}
|
|
1373
1743
|
|
|
1374
|
-
// Join a list with commas and a terminal
|
|
1375
|
-
// adds a serial comma before the
|
|
1744
|
+
// Join a list with commas and a terminal conjunction. The US dialect (Chicago)
|
|
1745
|
+
// adds a serial comma before the conjunction in lists of three or more; the UK
|
|
1376
1746
|
// dialect (Guardian) does not. Pairs never take one.
|
|
1377
|
-
function
|
|
1747
|
+
function joinWith(items: (string | number)[], conjunction: string,
|
|
1378
1748
|
opts: NormalizedOptions): string {
|
|
1379
1749
|
if (items.length <= 1) {
|
|
1380
1750
|
return items.join('');
|
|
1381
1751
|
}
|
|
1382
1752
|
|
|
1383
1753
|
if (items.length === 2) {
|
|
1384
|
-
return items[0] +
|
|
1754
|
+
return items[0] + conjunction + items[1];
|
|
1385
1755
|
}
|
|
1386
1756
|
|
|
1387
|
-
const
|
|
1757
|
+
const tail = opts.style.serialComma ? ',' + conjunction : conjunction;
|
|
1758
|
+
|
|
1759
|
+
return items.slice(0, -1).join(', ') + tail + items[items.length - 1];
|
|
1760
|
+
}
|
|
1388
1761
|
|
|
1389
|
-
|
|
1762
|
+
// Join a list with a terminal "and" (the default English connective).
|
|
1763
|
+
function joinList(items: (string | number)[],
|
|
1764
|
+
opts: NormalizedOptions): string {
|
|
1765
|
+
return joinWith(items, ' and ', opts);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// Join a list with a terminal "or", for an alternation such as a day-union
|
|
1769
|
+
// predicate list ("the 1st, a Sunday, or a weekday").
|
|
1770
|
+
function joinOr(items: (string | number)[],
|
|
1771
|
+
opts: NormalizedOptions): string {
|
|
1772
|
+
return joinWith(items, ' or ', opts);
|
|
1390
1773
|
}
|
|
1391
1774
|
|
|
1392
1775
|
// --- Day-level qualifiers. ---
|
|
@@ -1400,13 +1783,19 @@ interface QualifierWords {
|
|
|
1400
1783
|
month: string;
|
|
1401
1784
|
stepDate: string;
|
|
1402
1785
|
weekday: string;
|
|
1786
|
+
// A trailing weekday is a recurring schedule and reads plural ("on
|
|
1787
|
+
// Mondays"); a leading time-anchored one names the day singular ("every
|
|
1788
|
+
// Monday at 9 a.m.").
|
|
1789
|
+
recurringWeekday: boolean;
|
|
1403
1790
|
}
|
|
1404
1791
|
|
|
1405
|
-
const trailingWords: QualifierWords =
|
|
1406
|
-
|
|
1792
|
+
const trailingWords: QualifierWords = {
|
|
1793
|
+
all: '', month: 'in ', recurringWeekday: true, stepDate: 'on ', weekday: 'on '
|
|
1794
|
+
};
|
|
1407
1795
|
const leadingWords: QualifierWords = {
|
|
1408
1796
|
all: 'every day',
|
|
1409
1797
|
month: 'every day in ',
|
|
1798
|
+
recurringWeekday: false,
|
|
1410
1799
|
stepDate: '',
|
|
1411
1800
|
weekday: 'every '
|
|
1412
1801
|
};
|
|
@@ -1414,6 +1803,13 @@ const leadingWords: QualifierWords = {
|
|
|
1414
1803
|
// A trailing day-level qualifier for bare frequencies, e.g. " on Monday".
|
|
1415
1804
|
// Returns an empty string when no date, month, or weekday is set.
|
|
1416
1805
|
function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
|
|
1806
|
+
// A day union reframes both day fields as a trailing condition clause; the
|
|
1807
|
+
// month leads the whole description (applied in `describe`), so it is not
|
|
1808
|
+
// part of the trailing qualifier here.
|
|
1809
|
+
if (isDayUnion(ir, opts)) {
|
|
1810
|
+
return dayUnionCondition(ir, opts);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1417
1813
|
const phrase = dayQualifier(ir, trailingWords, opts);
|
|
1418
1814
|
|
|
1419
1815
|
return phrase && ' ' + phrase;
|
|
@@ -1422,6 +1818,13 @@ function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
|
|
|
1422
1818
|
// Build the day-level qualifier that precedes a specific time, e.g.
|
|
1423
1819
|
// "every day ", "every Friday ", or "on January 13 ".
|
|
1424
1820
|
function interpretDayQualifier(ir: IR, opts: NormalizedOptions): string {
|
|
1821
|
+
// A day union puts the time first ("at midnight whenever the day is …"), so
|
|
1822
|
+
// the leading position contributes no day phrase; the condition clause is
|
|
1823
|
+
// appended after the time by the clock renderer.
|
|
1824
|
+
if (isDayUnion(ir, opts)) {
|
|
1825
|
+
return '';
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1425
1828
|
return dayQualifier(ir, leadingWords, opts) + ' ';
|
|
1426
1829
|
}
|
|
1427
1830
|
|
|
@@ -1445,8 +1848,17 @@ function dayQualifier(ir: IR, words: QualifierWords,
|
|
|
1445
1848
|
// A weekday qualifier, optionally scoped to a month ("on Monday in
|
|
1446
1849
|
// June").
|
|
1447
1850
|
if (pattern.weekday !== '*') {
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1851
|
+
const quartzWeekday = quartzWeekdayPhrase(pattern.weekday, opts);
|
|
1852
|
+
|
|
1853
|
+
// The Quartz weekday phrase ("on the last Friday of the month") carries
|
|
1854
|
+
// the "of the month" recurrence a concrete month makes redundant; a plain
|
|
1855
|
+
// weekday name takes the ordinary " in <month>" scope.
|
|
1856
|
+
if (quartzWeekday) {
|
|
1857
|
+
return monthScopeForRecurrence(quartzWeekday, ir, opts);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const weekdays = words.weekday +
|
|
1861
|
+
weekdayPhrase(ir, words.recurringWeekday, opts);
|
|
1450
1862
|
|
|
1451
1863
|
return weekdays + monthScope(ir, opts);
|
|
1452
1864
|
}
|
|
@@ -1465,11 +1877,12 @@ function datePhrase(ir: IR, words: QualifierWords,
|
|
|
1465
1877
|
const quartzDate = quartzDatePhrase(pattern.date, opts);
|
|
1466
1878
|
|
|
1467
1879
|
if (quartzDate) {
|
|
1468
|
-
return quartzDate
|
|
1880
|
+
return monthScopeForRecurrence(quartzDate, ir, opts);
|
|
1469
1881
|
}
|
|
1470
1882
|
|
|
1471
1883
|
if (isOpenStep(pattern.date)) {
|
|
1472
|
-
return
|
|
1884
|
+
return monthScopeForRecurrence(
|
|
1885
|
+
words.stepDate + stepDates(pattern.date), ir, opts);
|
|
1473
1886
|
}
|
|
1474
1887
|
|
|
1475
1888
|
if (pattern.month !== '*' && !monthFoldsIntoDate(ir)) {
|
|
@@ -1497,31 +1910,197 @@ function monthFoldsIntoDate(ir: IR): boolean {
|
|
|
1497
1910
|
});
|
|
1498
1911
|
}
|
|
1499
1912
|
|
|
1913
|
+
// When BOTH the date and weekday are restricted, cron fires on the UNION of
|
|
1914
|
+
// the two day sets — a point the old "on <dom> or on <dow>" form blurred,
|
|
1915
|
+
// reading as alternatives (or, with "and", as an intersection). The default
|
|
1916
|
+
// dialect reframes the union as a predicate over a single variable, the day:
|
|
1917
|
+
// "whenever the day is <dom-predicate> or <dow-predicate(s)>", a flat or-list
|
|
1918
|
+
// that reads as a union for naive, logical, and technical readers alike. The
|
|
1919
|
+
// month leads the whole clause ("in June …") and the time/cadence sits between
|
|
1920
|
+
// the two, so this form is composed at the top level (see `dayUnionMonthLead`
|
|
1921
|
+
// and `dayUnionCondition`), not inside the trailing/leading qualifier. Scoped
|
|
1922
|
+
// to the until-window dialect; every other dialect and the `short` form keep
|
|
1923
|
+
// the established "on <dom> or on <dow>" phrasing.
|
|
1924
|
+
function isDayUnion(ir: IR, opts: NormalizedOptions): boolean {
|
|
1925
|
+
return ir.pattern.date !== '*' && ir.pattern.weekday !== '*' &&
|
|
1926
|
+
!!opts.style.untilWindow && !opts.short;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// The trailing condition clause for a day union, e.g. " whenever the day is
|
|
1930
|
+
// the 1st or a Friday". The day predicates are flattened into one or-list so
|
|
1931
|
+
// the union reads as a single set of matching days.
|
|
1932
|
+
function dayUnionCondition(ir: IR, opts: NormalizedOptions): string {
|
|
1933
|
+
const pieces = [...dayUnionDatePieces(ir, opts),
|
|
1934
|
+
...dayUnionWeekdayPieces(ir, opts)];
|
|
1935
|
+
|
|
1936
|
+
return ' whenever the day is ' + joinOr(pieces, opts);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// The leading "in <month> " scope for a day union, or an empty string when the
|
|
1940
|
+
// month is a wildcard. The month scopes the whole union, so it leads the clause
|
|
1941
|
+
// rather than attaching to either day half.
|
|
1942
|
+
function dayUnionMonthLead(ir: IR, opts: NormalizedOptions): string {
|
|
1943
|
+
if (ir.pattern.month === '*') {
|
|
1944
|
+
return '';
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
return 'in ' + monthName(ir, opts) + ' ';
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// The day-of-month half of a union as a flat list of predicate pieces. A
|
|
1951
|
+
// Quartz date is its definite phrase ("the last day of the month"); an open
|
|
1952
|
+
// `*/2`-style step is the parity idiom ("an odd-numbered day"); a plain field
|
|
1953
|
+
// reads each segment as "the <ordinal>" or "from the <ordinal> through the
|
|
1954
|
+
// <ordinal>".
|
|
1955
|
+
function dayUnionDatePieces(ir: IR, opts: NormalizedOptions): string[] {
|
|
1956
|
+
const dateField = ir.pattern.date;
|
|
1957
|
+
const quartz = quartzDatePhrase(dateField, opts);
|
|
1958
|
+
|
|
1959
|
+
if (quartz) {
|
|
1960
|
+
return [quartz.replace(/^on /, '')];
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
const oddEven = oddEvenDay(dateField);
|
|
1964
|
+
|
|
1965
|
+
if (oddEven) {
|
|
1966
|
+
return [oddEven];
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// Reached only with a restricted, non-Quartz date, which has segments. Each
|
|
1970
|
+
// segment contributes its predicate piece(s) to the flat union list; a step
|
|
1971
|
+
// spreads its enumerated fires as separate "the <ordinal>" alternatives.
|
|
1972
|
+
const pieces: string[] = [];
|
|
1973
|
+
|
|
1974
|
+
ir.analyses.segments.date!.forEach(function expand(segment) {
|
|
1975
|
+
if (segment.kind === 'range') {
|
|
1976
|
+
pieces.push('from the ' + getOrdinal(segment.bounds[0]) + through(opts) +
|
|
1977
|
+
'the ' + getOrdinal(segment.bounds[1]));
|
|
1978
|
+
}
|
|
1979
|
+
else if (segment.kind === 'step') {
|
|
1980
|
+
segment.fires.forEach(function fire(value) {
|
|
1981
|
+
pieces.push('the ' + getOrdinal(value));
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
else {
|
|
1985
|
+
pieces.push('the ' + getOrdinal(segment.value));
|
|
1986
|
+
}
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
return pieces;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// The day-of-week half of a union as a flat list of predicate pieces. A Quartz
|
|
1993
|
+
// weekday is its definite phrase ("the last Friday of the month"); the Monday-
|
|
1994
|
+
// through-Friday range is the "a weekday" idiom; every other weekday names each
|
|
1995
|
+
// day with the indefinite article ("a Friday", "a Sunday"), so each reads as a
|
|
1996
|
+
// kind of day the union can match.
|
|
1997
|
+
function dayUnionWeekdayPieces(ir: IR, opts: NormalizedOptions): string[] {
|
|
1998
|
+
const weekdayField = ir.pattern.weekday;
|
|
1999
|
+
const quartz = quartzWeekdayPhrase(weekdayField, opts);
|
|
2000
|
+
|
|
2001
|
+
if (quartz) {
|
|
2002
|
+
return [quartz.replace(/^on /, '')];
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// The union predicate keeps the canonical Sunday-first order (0…6) rather
|
|
2006
|
+
// than the weekend-last display order: as a flat or-list of day kinds, the
|
|
2007
|
+
// numeric order reads as naturally as any other and matches the reviewed
|
|
2008
|
+
// spec ("a Sunday, a Tuesday, a Thursday, or a Saturday").
|
|
2009
|
+
const pieces: string[] = [];
|
|
2010
|
+
|
|
2011
|
+
ir.analyses.segments.weekday!.forEach(function expand(segment) {
|
|
2012
|
+
if (segment.kind === 'range' &&
|
|
2013
|
+
segment.bounds[0] === '1' && segment.bounds[1] === '5') {
|
|
2014
|
+
pieces.push('a weekday');
|
|
2015
|
+
}
|
|
2016
|
+
else if (segment.kind === 'range') {
|
|
2017
|
+
pieces.push('a ' + getWeekday(segment.bounds[0], opts) + through(opts) +
|
|
2018
|
+
'a ' + getWeekday(segment.bounds[1], opts));
|
|
2019
|
+
}
|
|
2020
|
+
else if (segment.kind === 'step') {
|
|
2021
|
+
segment.fires.forEach(function fire(value) {
|
|
2022
|
+
pieces.push('a ' + getWeekday(value, opts));
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
else {
|
|
2026
|
+
pieces.push('a ' + getWeekday(segment.value, opts));
|
|
2027
|
+
}
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
return pieces;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// An interval-2 day-of-month step covering a parity set reads as "an
|
|
2034
|
+
// odd/even-numbered day", mirroring the month and year parity idioms: `*/2`
|
|
2035
|
+
// and `1/2` are the odd days, `2/2` the even; any other start enumerates
|
|
2036
|
+
// instead. Null when the field is not an open interval-2 step.
|
|
2037
|
+
function oddEvenDay(dateField: string): string | null {
|
|
2038
|
+
if (!isOpenStep(dateField)) {
|
|
2039
|
+
return null;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
const [start, step] = dateField.split('/');
|
|
2043
|
+
|
|
2044
|
+
if (+step !== 2) {
|
|
2045
|
+
return null;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
if (start === '*' || start === '1') {
|
|
2049
|
+
return 'an odd-numbered day';
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
return start === '2' ? 'an even-numbered day' : null;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
1500
2055
|
// Compose the "day-of-month or day-of-week" phrase used when both fields
|
|
1501
2056
|
// are restricted: cron fires when either is a match. A restricted month
|
|
1502
|
-
// scopes
|
|
2057
|
+
// scopes BOTH halves, so it attaches to the whole or, never to a single
|
|
2058
|
+
// branch. When the month folds into a calendar date ("on June 13") it also
|
|
2059
|
+
// names itself on the weekday ("or on Friday in June"), keeping both halves
|
|
2060
|
+
// scoped; otherwise (a Quartz date, an open day step, a month range, or the
|
|
2061
|
+
// odd/even frequency) it trails the whole or as ", in <month>".
|
|
1503
2062
|
function dateOrWeekday(ir: IR, opts: NormalizedOptions): string {
|
|
1504
2063
|
const pattern = ir.pattern;
|
|
2064
|
+
// The day-of-month-OR-day-of-week union is out of scope for the recurring
|
|
2065
|
+
// plural (it is reframed elsewhere): the weekday half stays singular here.
|
|
1505
2066
|
const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) ||
|
|
1506
|
-
'on ' + weekdayPhrase(ir, opts);
|
|
2067
|
+
'on ' + weekdayPhrase(ir, false, opts);
|
|
2068
|
+
|
|
2069
|
+
if (pattern.month !== '*' && monthFoldsIntoDate(ir) &&
|
|
2070
|
+
!quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
|
|
2071
|
+
return 'on ' + monthDatePhrase(ir, opts) + ' or ' + weekdayPart +
|
|
2072
|
+
' in ' + monthName(ir, opts);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
return datePart(ir, opts) + ' or ' + weekdayPart + orMonthScope(ir, opts);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// The day-of-month half of an or-day phrase, without any month scope (the
|
|
2079
|
+
// month scopes the whole or, applied by the caller).
|
|
2080
|
+
function datePart(ir: IR, opts: NormalizedOptions): string {
|
|
2081
|
+
const pattern = ir.pattern;
|
|
1507
2082
|
const quartzDate = quartzDatePhrase(pattern.date, opts);
|
|
1508
2083
|
|
|
1509
2084
|
if (quartzDate) {
|
|
1510
|
-
return quartzDate
|
|
2085
|
+
return quartzDate;
|
|
1511
2086
|
}
|
|
1512
2087
|
|
|
1513
2088
|
if (isOpenStep(pattern.date)) {
|
|
1514
|
-
return stepDates(pattern.date)
|
|
1515
|
-
weekdayPart;
|
|
2089
|
+
return stepDates(pattern.date);
|
|
1516
2090
|
}
|
|
1517
2091
|
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
2092
|
+
return 'on the ' + dateOrdinals(ir, opts);
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// A trailing month scope for the whole or, set off by a comma so it reads
|
|
2096
|
+
// over both day halves ("…or on Friday, in June"); empty when the month is a
|
|
2097
|
+
// wildcard.
|
|
2098
|
+
function orMonthScope(ir: IR, opts: NormalizedOptions): string {
|
|
2099
|
+
if (ir.pattern.month === '*') {
|
|
2100
|
+
return '';
|
|
1521
2101
|
}
|
|
1522
2102
|
|
|
1523
|
-
return '
|
|
1524
|
-
monthScope(ir, opts);
|
|
2103
|
+
return ', in ' + monthName(ir, opts);
|
|
1525
2104
|
}
|
|
1526
2105
|
|
|
1527
2106
|
// The day-qualifier phrase for a Quartz date field (e.g. "on the last day
|
|
@@ -1572,6 +2151,12 @@ function quartzWeekdayPhrase(weekdayField: string,
|
|
|
1572
2151
|
// A calendar date with its month, in the dialect's order and day form:
|
|
1573
2152
|
// cardinal "January 1" / "1 January", or ordinal "January 1st" for
|
|
1574
2153
|
// dialects that set `ordinals`.
|
|
2154
|
+
//
|
|
2155
|
+
// A day-first dialect places the day before the month, but a single day before
|
|
2156
|
+
// a MULTI-month list garden-paths — "13 January, April, July and October"
|
|
2157
|
+
// reads as if the 13 belongs to January alone. The day is reattached to the
|
|
2158
|
+
// whole list with the possessive "the <ordinal> of <months>", which names the
|
|
2159
|
+
// same day across every month unambiguously.
|
|
1575
2160
|
function monthDatePhrase(ir: IR, opts: NormalizedOptions): string {
|
|
1576
2161
|
const month = monthName(ir, opts);
|
|
1577
2162
|
// A month-day phrase is reached only with a restricted date, which has
|
|
@@ -1579,6 +2164,11 @@ function monthDatePhrase(ir: IR, opts: NormalizedOptions): string {
|
|
|
1579
2164
|
const days = renderSegments(ir.analyses.segments.date!,
|
|
1580
2165
|
opts.style.ordinals ? getOrdinal : cardinalDay, opts);
|
|
1581
2166
|
|
|
2167
|
+
if (opts.style.dayFirst && ir.shapes.date === 'single' &&
|
|
2168
|
+
ir.shapes.month !== 'single') {
|
|
2169
|
+
return 'the ' + getOrdinal(ir.pattern.date) + ' of ' + month;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
1582
2172
|
return opts.style.dayFirst ? days + ' ' + month : month + ' ' + days;
|
|
1583
2173
|
}
|
|
1584
2174
|
|
|
@@ -1597,6 +2187,35 @@ function monthScope(ir: IR, opts: NormalizedOptions): string {
|
|
|
1597
2187
|
return ' in ' + monthName(ir, opts);
|
|
1598
2188
|
}
|
|
1599
2189
|
|
|
2190
|
+
// Scope a phrase that ends in the recurrence "of the month" (the Quartz last-
|
|
2191
|
+
// day / last-weekday / nth-weekday forms and the open day-of-month step) by a
|
|
2192
|
+
// named month. A concrete month — a single name or a step ("every odd-numbered
|
|
2193
|
+
// month", "January, April, …") — makes "of the month" redundant: it names that
|
|
2194
|
+
// one month, so the phrase drops it and reads "in <month>". A month RANGE
|
|
2195
|
+
// distributes the recurrence across the span and keeps it, rephrased as "of
|
|
2196
|
+
// each month from <first> through <last>". A month list is left as-is (the
|
|
2197
|
+
// recurrence stays, scoped "in <names>"), and a wildcard month adds nothing.
|
|
2198
|
+
function monthScopeForRecurrence(phrase: string, ir: IR,
|
|
2199
|
+
opts: NormalizedOptions): string {
|
|
2200
|
+
if (ir.pattern.month === '*') {
|
|
2201
|
+
return phrase;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
const carriesRecurrence = phrase.indexOf(' of the month') !== -1;
|
|
2205
|
+
|
|
2206
|
+
if (carriesRecurrence && ir.shapes.month === 'range') {
|
|
2207
|
+
return phrase.replace(' of the month', ' of each month') + ' from ' +
|
|
2208
|
+
monthName(ir, opts);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
if (carriesRecurrence &&
|
|
2212
|
+
(ir.shapes.month === 'single' || ir.shapes.month === 'step')) {
|
|
2213
|
+
return phrase.replace(' of the month', '') + ' in ' + monthName(ir, opts);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
return phrase + ' in ' + monthName(ir, opts);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
1600
2219
|
// Frequency phrase for an open day-of-month step, e.g. "every other day of
|
|
1601
2220
|
// the month" or "every 3rd day of the month from the 5th".
|
|
1602
2221
|
function stepDates(dateField: string): string {
|
|
@@ -1663,12 +2282,44 @@ function oddEvenMonth(monthField: string): string | null {
|
|
|
1663
2282
|
}
|
|
1664
2283
|
|
|
1665
2284
|
// Render the weekday field as names. Ranges read in their connective form
|
|
1666
|
-
// ("Monday through Friday", or "Mon-Fri" with `short`).
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
2285
|
+
// ("Monday through Friday", or "Mon-Fri" with `short`). When `recurring`, a
|
|
2286
|
+
// trailing single or list weekday is a repeating schedule and reads plural
|
|
2287
|
+
// ("on Mondays", "on Mondays and Wednesdays"), matching es/de/fi; a RANGE
|
|
2288
|
+
// keeps the singular idiom ("on Monday through Friday") so its through-
|
|
2289
|
+
// connective stays unmistakable, and a leading time-anchored form ("every
|
|
2290
|
+
// Monday") is never recurring here.
|
|
2291
|
+
function weekdayPhrase(ir: IR, recurring: boolean,
|
|
2292
|
+
opts: NormalizedOptions): string {
|
|
2293
|
+
// Reached only with a restricted weekday, which has segments. Weekday lists
|
|
2294
|
+
// display Monday-first (Sunday last) so a weekend reads naturally; the IR
|
|
2295
|
+
// stays canonical (Sunday=0) and ranges keep their form.
|
|
2296
|
+
const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday!);
|
|
2297
|
+
const hasRange = segments.some(function range(segment) {
|
|
2298
|
+
return segment.kind === 'range';
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
// A range pins the singular idiom for the whole phrase ("Monday through
|
|
2302
|
+
// Friday"); only an all-single/step set pluralizes its names.
|
|
2303
|
+
const name = recurring && !hasRange ?
|
|
2304
|
+
function plural(value: number | string): string {
|
|
2305
|
+
return pluralWeekday(value, opts);
|
|
2306
|
+
} :
|
|
2307
|
+
function singular(value: number | string): string {
|
|
2308
|
+
return getWeekday(value, opts);
|
|
2309
|
+
};
|
|
2310
|
+
|
|
2311
|
+
return renderSegments(segments, name, opts);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// The recurring (plural) form of a weekday name: every English weekday name
|
|
2315
|
+
// pluralizes by appending "s" ("Mondays", "Sundays"). The `short`
|
|
2316
|
+
// abbreviation keeps its singular form — "on Mons" reads as an error, not a
|
|
2317
|
+
// plural.
|
|
2318
|
+
function pluralWeekday(value: number | string,
|
|
2319
|
+
opts: NormalizedOptions): string {
|
|
2320
|
+
const name = getWeekday(value, opts);
|
|
2321
|
+
|
|
2322
|
+
return opts.short ? name : name + 's';
|
|
1672
2323
|
}
|
|
1673
2324
|
|
|
1674
2325
|
// Render classified field segments with `word`, expanding step segments
|
|
@@ -1715,7 +2366,10 @@ function applyYear(description: string, ir: IR,
|
|
|
1715
2366
|
}
|
|
1716
2367
|
|
|
1717
2368
|
if (yearField.indexOf('/') !== -1) {
|
|
1718
|
-
|
|
2369
|
+
// A year step is a coarser cadence juxtaposed on the finished clause: a
|
|
2370
|
+
// clause comma separates it ("every second, every other year"), matching
|
|
2371
|
+
// how every other juxtaposed clause is joined.
|
|
2372
|
+
return description + ', ' + stepYears(yearField, opts);
|
|
1719
2373
|
}
|
|
1720
2374
|
|
|
1721
2375
|
const label = yearLabel(yearField, opts);
|
|
@@ -1738,6 +2392,12 @@ function yearLabel(yearField: string, opts: NormalizedOptions): string {
|
|
|
1738
2392
|
return joinList(yearField.split(','), opts);
|
|
1739
2393
|
}
|
|
1740
2394
|
|
|
2395
|
+
// A year range reads with the dialect's range connective ("2030 through
|
|
2396
|
+
// 2035"), the same form every other field uses, not a raw hyphen.
|
|
2397
|
+
if (yearField.indexOf('-') !== -1) {
|
|
2398
|
+
return yearField.split('-').join(through(opts));
|
|
2399
|
+
}
|
|
2400
|
+
|
|
1741
2401
|
return yearField;
|
|
1742
2402
|
}
|
|
1743
2403
|
|
|
@@ -1752,7 +2412,11 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
|
|
|
1752
2412
|
return 'every year';
|
|
1753
2413
|
}
|
|
1754
2414
|
|
|
1755
|
-
|
|
2415
|
+
// Interval 2 reads as the parity idiom ("every other year"), matching the
|
|
2416
|
+
// month and day-of-month step forms; longer intervals count the years.
|
|
2417
|
+
let phrase = interval === 2 ?
|
|
2418
|
+
'every other year' :
|
|
2419
|
+
'every ' + getNumber(interval, opts) + ' years';
|
|
1756
2420
|
|
|
1757
2421
|
if (start !== '*' && start !== '0') {
|
|
1758
2422
|
phrase += ' from ' + start;
|