cronli5 0.1.7 → 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 +43 -0
- package/README.md +5 -5
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +363 -75
- package/dist/cronli5.js +363 -75
- package/dist/lang/en.cjs +363 -75
- package/dist/lang/en.js +363 -75
- package/package.json +1 -1
- package/src/core/ir.ts +5 -0
- package/src/lang/en/dialects.ts +6 -2
- package/src/lang/en/index.ts +735 -102
- package/types/core/ir.d.ts +1 -0
package/src/lang/en/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
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';
|
|
@@ -609,13 +642,35 @@ function boundedWindow(plan: PlanOf<'hourRange'>):
|
|
|
609
642
|
return {from: plan.from, last, to: plan.to};
|
|
610
643
|
}
|
|
611
644
|
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
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);
|
|
666
|
+
}
|
|
667
|
+
|
|
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.
|
|
615
671
|
function hourWindow(window: {from: number; to: number; last: number},
|
|
616
672
|
opts: NormalizedOptions): string {
|
|
617
|
-
return
|
|
618
|
-
through(opts) + getTime({hour: window.to, minute: window.last}, opts);
|
|
673
|
+
return rangeWindow(window.from, window.to, window.last, opts);
|
|
619
674
|
}
|
|
620
675
|
|
|
621
676
|
// Expand a discrete set of hours and minutes into clock times prefixed by
|
|
@@ -645,7 +700,15 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
|
645
700
|
}, opts);
|
|
646
701
|
});
|
|
647
702
|
|
|
648
|
-
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) : '';
|
|
649
712
|
}
|
|
650
713
|
|
|
651
714
|
// Compact form for a clock-time set past the enumeration cap. A single
|
|
@@ -679,7 +742,7 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
|
679
742
|
const fold = {minute: plan.minute, second: ir.analyses.clockSecond};
|
|
680
743
|
|
|
681
744
|
return interpretDayQualifier(ir, opts) + 'at ' +
|
|
682
|
-
hourSegmentTimes(ir, fold, true, opts);
|
|
745
|
+
hourSegmentTimes(ir, fold, true, opts) + dayUnionTrail(ir, opts);
|
|
683
746
|
}
|
|
684
747
|
|
|
685
748
|
const minuteLead =
|
|
@@ -706,39 +769,330 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
|
706
769
|
|
|
707
770
|
// A folded hour field that includes a contiguous range reads with the
|
|
708
771
|
// hour-range frame: a shared minute lead ("every hour" / "at 30 minutes
|
|
709
|
-
// past the hour"), each range as a
|
|
710
|
-
//
|
|
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").
|
|
711
775
|
function foldedHourWindows(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
712
776
|
opts: NormalizedOptions): string {
|
|
713
777
|
const minute = plan.minute;
|
|
714
778
|
const windows: string[] = [];
|
|
715
|
-
const
|
|
779
|
+
const outliers = collectHourOutliers(ir);
|
|
780
|
+
const times = outliers.hours.map(function time(hour) {
|
|
781
|
+
return getTime({hour, minute}, opts);
|
|
782
|
+
});
|
|
716
783
|
|
|
717
784
|
// Reached only via the fold branch under discrete hours, which have
|
|
718
785
|
// segments.
|
|
719
786
|
ir.analyses.segments.hour!.forEach(function classify(segment) {
|
|
720
787
|
if (segment.kind === 'range') {
|
|
721
|
-
windows.push(
|
|
722
|
-
|
|
723
|
-
getTime({hour: segment.bounds[1], minute}, opts));
|
|
788
|
+
windows.push(rangeWindow(+segment.bounds[0], +segment.bounds[1],
|
|
789
|
+
minute, opts));
|
|
724
790
|
}
|
|
725
|
-
|
|
726
|
-
|
|
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;
|
|
727
812
|
}
|
|
728
|
-
else {
|
|
729
|
-
|
|
813
|
+
else if (segment.kind !== 'range') {
|
|
814
|
+
hours.push(+segment.value);
|
|
730
815
|
}
|
|
731
816
|
});
|
|
732
817
|
|
|
733
|
-
|
|
818
|
+
return {hours, pureStrays};
|
|
819
|
+
}
|
|
734
820
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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 '';
|
|
739
832
|
}
|
|
740
833
|
|
|
741
|
-
|
|
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);
|
|
881
|
+
|
|
882
|
+
return {secondLead: false, text};
|
|
883
|
+
}
|
|
884
|
+
|
|
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);
|
|
742
1096
|
}
|
|
743
1097
|
|
|
744
1098
|
// The plan dispatch table.
|
|
@@ -790,10 +1144,9 @@ function renderStride(stride: Stride, opts: NormalizedOptions): string {
|
|
|
790
1144
|
pluralize(start, unit) + ' past the ' + anchor;
|
|
791
1145
|
}
|
|
792
1146
|
|
|
793
|
-
// A bounded, non-wrapping set: pin both endpoints.
|
|
794
|
-
//
|
|
795
|
-
|
|
796
|
-
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();
|
|
797
1150
|
|
|
798
1151
|
return cadence + ' from ' + num(start) + through(opts) + num(last) + ' ' +
|
|
799
1152
|
pluralize(last, unit) + ' past the ' + anchor;
|
|
@@ -1083,12 +1436,12 @@ function hourCadence(ir: IR, minute: number,
|
|
|
1083
1436
|
// minute during every other hour", matching the "every minute during every
|
|
1084
1437
|
// other hour" idiom and keeping it distinct from the bare hour-step form
|
|
1085
1438
|
// ("every two hours") so the minute-0 confinement is never heard as it.
|
|
1086
|
-
const
|
|
1439
|
+
const minuteZeroStride = minute === 0 && subMinuteSecond(ir) &&
|
|
1087
1440
|
cleanStrideSegment(ir);
|
|
1088
1441
|
|
|
1089
|
-
if (
|
|
1442
|
+
if (minuteZeroStride) {
|
|
1090
1443
|
return secondsClause(ir, 'minute', opts) + ' for one minute ' +
|
|
1091
|
-
everyNthHour(
|
|
1444
|
+
everyNthHour(minuteZeroStride, opts) + trailingQualifier(ir, opts);
|
|
1092
1445
|
}
|
|
1093
1446
|
|
|
1094
1447
|
// A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
|
|
@@ -1135,38 +1488,29 @@ function hasHourWindow(ir: IR): boolean {
|
|
|
1135
1488
|
}
|
|
1136
1489
|
|
|
1137
1490
|
// The hour-range window as a cadence tail at the top of each hour: each range
|
|
1138
|
-
// segment is a
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
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.
|
|
1142
1496
|
function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
|
|
1143
1497
|
const windows: string[] = [];
|
|
1144
|
-
const
|
|
1498
|
+
const outliers = collectHourOutliers(ir);
|
|
1145
1499
|
|
|
1146
1500
|
// Reached only after hasHourWindow, so hour segments exist.
|
|
1147
1501
|
ir.analyses.segments.hour!.forEach(function classify(segment) {
|
|
1148
1502
|
if (segment.kind === 'range') {
|
|
1149
|
-
windows.push(
|
|
1150
|
-
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);
|
|
1503
|
+
windows.push(rangeWindow(+segment.bounds[0], +segment.bounds[1], 0,
|
|
1504
|
+
opts));
|
|
1158
1505
|
}
|
|
1159
1506
|
});
|
|
1160
1507
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
return getTime({hour, minute: 0}, opts);
|
|
1166
|
-
}), opts);
|
|
1167
|
-
}
|
|
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
|
+
});
|
|
1168
1512
|
|
|
1169
|
-
return phrase;
|
|
1513
|
+
return phrase + outlierTail(times, outliers.pureStrays, opts);
|
|
1170
1514
|
}
|
|
1171
1515
|
|
|
1172
1516
|
// Render an hour range (or a list whose segments include a range) under a
|
|
@@ -1211,41 +1555,55 @@ function hourRangeCadence(ir: IR, minute: number,
|
|
|
1211
1555
|
|
|
1212
1556
|
// --- List and segment phrasing. ---
|
|
1213
1557
|
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
const anyBig = values.some(function big(v) {
|
|
1221
|
-
return +v > 10;
|
|
1222
|
-
});
|
|
1223
|
-
|
|
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 {
|
|
1224
1564
|
return function format(n) {
|
|
1225
|
-
return
|
|
1565
|
+
return '' + n;
|
|
1226
1566
|
};
|
|
1227
1567
|
}
|
|
1228
1568
|
|
|
1229
|
-
//
|
|
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`).
|
|
1230
1588
|
function numberWords(fires: number[],
|
|
1231
1589
|
opts: NormalizedOptions): (string | number)[] {
|
|
1232
|
-
return fires.map(
|
|
1590
|
+
return fires.map(listNumber(fires.length, opts));
|
|
1233
1591
|
}
|
|
1234
1592
|
|
|
1235
|
-
// Render classified segments as words
|
|
1236
|
-
// "<a> through <b>" pairs, step segments as their
|
|
1237
|
-
//
|
|
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`).
|
|
1238
1597
|
function segmentWords(segments: Segment[],
|
|
1239
1598
|
opts: NormalizedOptions): (string | number)[] {
|
|
1240
|
-
const
|
|
1241
|
-
(string | number)[] {
|
|
1599
|
+
const count = segments.reduce(function tally(sum, segment) {
|
|
1242
1600
|
if (segment.kind === 'range') {
|
|
1243
|
-
return
|
|
1601
|
+
return sum + 1;
|
|
1244
1602
|
}
|
|
1245
1603
|
|
|
1246
|
-
return segment.kind === 'step' ? segment.fires :
|
|
1247
|
-
});
|
|
1248
|
-
const num =
|
|
1604
|
+
return sum + (segment.kind === 'step' ? segment.fires.length : 1);
|
|
1605
|
+
}, 0);
|
|
1606
|
+
const num = listNumber(count, opts);
|
|
1249
1607
|
|
|
1250
1608
|
return segments.flatMap(function word(segment) {
|
|
1251
1609
|
if (segment.kind === 'range') {
|
|
@@ -1298,6 +1656,13 @@ function hourTimes(hours: number[], opts: NormalizedOptions): string {
|
|
|
1298
1656
|
return joinList(times, opts);
|
|
1299
1657
|
}
|
|
1300
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
|
+
|
|
1301
1666
|
// The hour times accompanying a window phrase: enumerated fires up to the
|
|
1302
1667
|
// cap, segment rendering past it (decided by the core). `atContext` marks
|
|
1303
1668
|
// an "at <times>" frame (vs "during the <times> hours").
|
|
@@ -1376,22 +1741,35 @@ function disambiguateTimes(pieces: string[], segments: Segment[],
|
|
|
1376
1741
|
});
|
|
1377
1742
|
}
|
|
1378
1743
|
|
|
1379
|
-
// Join a list with commas and a terminal
|
|
1380
|
-
// 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
|
|
1381
1746
|
// dialect (Guardian) does not. Pairs never take one.
|
|
1382
|
-
function
|
|
1747
|
+
function joinWith(items: (string | number)[], conjunction: string,
|
|
1383
1748
|
opts: NormalizedOptions): string {
|
|
1384
1749
|
if (items.length <= 1) {
|
|
1385
1750
|
return items.join('');
|
|
1386
1751
|
}
|
|
1387
1752
|
|
|
1388
1753
|
if (items.length === 2) {
|
|
1389
|
-
return items[0] +
|
|
1754
|
+
return items[0] + conjunction + items[1];
|
|
1390
1755
|
}
|
|
1391
1756
|
|
|
1392
|
-
const
|
|
1757
|
+
const tail = opts.style.serialComma ? ',' + conjunction : conjunction;
|
|
1758
|
+
|
|
1759
|
+
return items.slice(0, -1).join(', ') + tail + items[items.length - 1];
|
|
1760
|
+
}
|
|
1761
|
+
|
|
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
|
+
}
|
|
1393
1767
|
|
|
1394
|
-
|
|
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);
|
|
1395
1773
|
}
|
|
1396
1774
|
|
|
1397
1775
|
// --- Day-level qualifiers. ---
|
|
@@ -1405,13 +1783,19 @@ interface QualifierWords {
|
|
|
1405
1783
|
month: string;
|
|
1406
1784
|
stepDate: string;
|
|
1407
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;
|
|
1408
1790
|
}
|
|
1409
1791
|
|
|
1410
|
-
const trailingWords: QualifierWords =
|
|
1411
|
-
|
|
1792
|
+
const trailingWords: QualifierWords = {
|
|
1793
|
+
all: '', month: 'in ', recurringWeekday: true, stepDate: 'on ', weekday: 'on '
|
|
1794
|
+
};
|
|
1412
1795
|
const leadingWords: QualifierWords = {
|
|
1413
1796
|
all: 'every day',
|
|
1414
1797
|
month: 'every day in ',
|
|
1798
|
+
recurringWeekday: false,
|
|
1415
1799
|
stepDate: '',
|
|
1416
1800
|
weekday: 'every '
|
|
1417
1801
|
};
|
|
@@ -1419,6 +1803,13 @@ const leadingWords: QualifierWords = {
|
|
|
1419
1803
|
// A trailing day-level qualifier for bare frequencies, e.g. " on Monday".
|
|
1420
1804
|
// Returns an empty string when no date, month, or weekday is set.
|
|
1421
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
|
+
|
|
1422
1813
|
const phrase = dayQualifier(ir, trailingWords, opts);
|
|
1423
1814
|
|
|
1424
1815
|
return phrase && ' ' + phrase;
|
|
@@ -1427,6 +1818,13 @@ function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
|
|
|
1427
1818
|
// Build the day-level qualifier that precedes a specific time, e.g.
|
|
1428
1819
|
// "every day ", "every Friday ", or "on January 13 ".
|
|
1429
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
|
+
|
|
1430
1828
|
return dayQualifier(ir, leadingWords, opts) + ' ';
|
|
1431
1829
|
}
|
|
1432
1830
|
|
|
@@ -1450,8 +1848,17 @@ function dayQualifier(ir: IR, words: QualifierWords,
|
|
|
1450
1848
|
// A weekday qualifier, optionally scoped to a month ("on Monday in
|
|
1451
1849
|
// June").
|
|
1452
1850
|
if (pattern.weekday !== '*') {
|
|
1453
|
-
const
|
|
1454
|
-
|
|
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);
|
|
1455
1862
|
|
|
1456
1863
|
return weekdays + monthScope(ir, opts);
|
|
1457
1864
|
}
|
|
@@ -1470,11 +1877,12 @@ function datePhrase(ir: IR, words: QualifierWords,
|
|
|
1470
1877
|
const quartzDate = quartzDatePhrase(pattern.date, opts);
|
|
1471
1878
|
|
|
1472
1879
|
if (quartzDate) {
|
|
1473
|
-
return quartzDate
|
|
1880
|
+
return monthScopeForRecurrence(quartzDate, ir, opts);
|
|
1474
1881
|
}
|
|
1475
1882
|
|
|
1476
1883
|
if (isOpenStep(pattern.date)) {
|
|
1477
|
-
return
|
|
1884
|
+
return monthScopeForRecurrence(
|
|
1885
|
+
words.stepDate + stepDates(pattern.date), ir, opts);
|
|
1478
1886
|
}
|
|
1479
1887
|
|
|
1480
1888
|
if (pattern.month !== '*' && !monthFoldsIntoDate(ir)) {
|
|
@@ -1502,6 +1910,148 @@ function monthFoldsIntoDate(ir: IR): boolean {
|
|
|
1502
1910
|
});
|
|
1503
1911
|
}
|
|
1504
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
|
+
|
|
1505
2055
|
// Compose the "day-of-month or day-of-week" phrase used when both fields
|
|
1506
2056
|
// are restricted: cron fires when either is a match. A restricted month
|
|
1507
2057
|
// scopes BOTH halves, so it attaches to the whole or, never to a single
|
|
@@ -1511,8 +2061,10 @@ function monthFoldsIntoDate(ir: IR): boolean {
|
|
|
1511
2061
|
// odd/even frequency) it trails the whole or as ", in <month>".
|
|
1512
2062
|
function dateOrWeekday(ir: IR, opts: NormalizedOptions): string {
|
|
1513
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.
|
|
1514
2066
|
const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) ||
|
|
1515
|
-
'on ' + weekdayPhrase(ir, opts);
|
|
2067
|
+
'on ' + weekdayPhrase(ir, false, opts);
|
|
1516
2068
|
|
|
1517
2069
|
if (pattern.month !== '*' && monthFoldsIntoDate(ir) &&
|
|
1518
2070
|
!quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
|
|
@@ -1599,6 +2151,12 @@ function quartzWeekdayPhrase(weekdayField: string,
|
|
|
1599
2151
|
// A calendar date with its month, in the dialect's order and day form:
|
|
1600
2152
|
// cardinal "January 1" / "1 January", or ordinal "January 1st" for
|
|
1601
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.
|
|
1602
2160
|
function monthDatePhrase(ir: IR, opts: NormalizedOptions): string {
|
|
1603
2161
|
const month = monthName(ir, opts);
|
|
1604
2162
|
// A month-day phrase is reached only with a restricted date, which has
|
|
@@ -1606,6 +2164,11 @@ function monthDatePhrase(ir: IR, opts: NormalizedOptions): string {
|
|
|
1606
2164
|
const days = renderSegments(ir.analyses.segments.date!,
|
|
1607
2165
|
opts.style.ordinals ? getOrdinal : cardinalDay, opts);
|
|
1608
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
|
+
|
|
1609
2172
|
return opts.style.dayFirst ? days + ' ' + month : month + ' ' + days;
|
|
1610
2173
|
}
|
|
1611
2174
|
|
|
@@ -1624,6 +2187,35 @@ function monthScope(ir: IR, opts: NormalizedOptions): string {
|
|
|
1624
2187
|
return ' in ' + monthName(ir, opts);
|
|
1625
2188
|
}
|
|
1626
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
|
+
|
|
1627
2219
|
// Frequency phrase for an open day-of-month step, e.g. "every other day of
|
|
1628
2220
|
// the month" or "every 3rd day of the month from the 5th".
|
|
1629
2221
|
function stepDates(dateField: string): string {
|
|
@@ -1690,16 +2282,44 @@ function oddEvenMonth(monthField: string): string | null {
|
|
|
1690
2282
|
}
|
|
1691
2283
|
|
|
1692
2284
|
// Render the weekday field as names. Ranges read in their connective form
|
|
1693
|
-
// ("Monday through Friday", or "Mon-Fri" with `short`).
|
|
1694
|
-
|
|
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 {
|
|
1695
2293
|
// Reached only with a restricted weekday, which has segments. Weekday lists
|
|
1696
2294
|
// display Monday-first (Sunday last) so a weekend reads naturally; the IR
|
|
1697
2295
|
// stays canonical (Sunday=0) and ranges keep their form.
|
|
1698
2296
|
const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday!);
|
|
2297
|
+
const hasRange = segments.some(function range(segment) {
|
|
2298
|
+
return segment.kind === 'range';
|
|
2299
|
+
});
|
|
1699
2300
|
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
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';
|
|
1703
2323
|
}
|
|
1704
2324
|
|
|
1705
2325
|
// Render classified field segments with `word`, expanding step segments
|
|
@@ -1746,7 +2366,10 @@ function applyYear(description: string, ir: IR,
|
|
|
1746
2366
|
}
|
|
1747
2367
|
|
|
1748
2368
|
if (yearField.indexOf('/') !== -1) {
|
|
1749
|
-
|
|
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);
|
|
1750
2373
|
}
|
|
1751
2374
|
|
|
1752
2375
|
const label = yearLabel(yearField, opts);
|
|
@@ -1769,6 +2392,12 @@ function yearLabel(yearField: string, opts: NormalizedOptions): string {
|
|
|
1769
2392
|
return joinList(yearField.split(','), opts);
|
|
1770
2393
|
}
|
|
1771
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
|
+
|
|
1772
2401
|
return yearField;
|
|
1773
2402
|
}
|
|
1774
2403
|
|
|
@@ -1783,7 +2412,11 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
|
|
|
1783
2412
|
return 'every year';
|
|
1784
2413
|
}
|
|
1785
2414
|
|
|
1786
|
-
|
|
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';
|
|
1787
2420
|
|
|
1788
2421
|
if (start !== '*' && start !== '0') {
|
|
1789
2422
|
phrase += ' from ' + start;
|