cronli5 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lang/en.cjs CHANGED
@@ -60,6 +60,48 @@ function orderWeekdaysForDisplay(segments) {
60
60
  return pair[0];
61
61
  });
62
62
  }
63
+ function segmentsOf(ir, field) {
64
+ return ir.analyses.segments[field] ?? [];
65
+ }
66
+ function stepSegment(ir, field) {
67
+ return segmentsOf(ir, field)[0];
68
+ }
69
+ function singleValues(segments) {
70
+ const values = [];
71
+ for (const segment of segments) {
72
+ if (segment.kind !== "single") {
73
+ return null;
74
+ }
75
+ values.push(+segment.value);
76
+ }
77
+ return values;
78
+ }
79
+ function offsetCleanStride(stride) {
80
+ return stride.start < stride.interval && 24 % stride.interval === 0;
81
+ }
82
+ function hourListStride(values) {
83
+ if (values.length < 2) {
84
+ return null;
85
+ }
86
+ const interval = values[1] - values[0];
87
+ if (interval < 2) {
88
+ return null;
89
+ }
90
+ for (let i = 2; i < values.length; i += 1) {
91
+ if (values[i] - values[i - 1] !== interval) {
92
+ return null;
93
+ }
94
+ }
95
+ if (values[0] !== 0 && values.length < 5) {
96
+ return null;
97
+ }
98
+ return { interval, last: values[values.length - 1], start: values[0] };
99
+ }
100
+
101
+ // src/core/shapes.ts
102
+ function isOpenStep(field) {
103
+ return field.indexOf("/") !== -1 && field.indexOf("-") === -1 && field.indexOf(",") === -1;
104
+ }
63
105
 
64
106
  // src/core/specs.ts
65
107
  var maxClockTimes = 6;
@@ -104,7 +146,8 @@ var dialects = {
104
146
  pm: "p.m.",
105
147
  sep: ":",
106
148
  serialComma: true,
107
- through: " through "
149
+ through: " through ",
150
+ untilWindow: true
108
151
  },
109
152
  house: {
110
153
  am: "AM",
@@ -121,7 +164,7 @@ var dialects = {
121
164
  };
122
165
  function resolveDialect(dialect) {
123
166
  if (typeof dialect === "object" && dialect !== null) {
124
- return { ...dialects.us, ...dialect };
167
+ return { ...dialects.us, untilWindow: false, ...dialect };
125
168
  }
126
169
  const name = dialect === "uk" ? "gb" : dialect;
127
170
  return dialects[name] || dialects.us;
@@ -193,7 +236,9 @@ function normalizeOptions(options) {
193
236
  };
194
237
  }
195
238
  function describe(ir, opts) {
196
- return applyYear(render(ir, ir.plan, opts), ir, opts);
239
+ const body = confinement(ir, opts) ?? render(ir, ir.plan, opts);
240
+ const lead = isDayUnion(ir, opts) ? dayUnionMonthLead(ir, opts) : "";
241
+ return applyYear(lead + body, ir, opts);
197
242
  }
198
243
  function render(ir, plan, opts) {
199
244
  const renderer = renderers[plan.kind];
@@ -278,7 +323,7 @@ function secondsClause(ir, anchor, opts) {
278
323
  }
279
324
  if (shape === "step") {
280
325
  return stepCycle60(
281
- ir.analyses.segments.second[0],
326
+ stepSegment(ir, "second"),
282
327
  "second",
283
328
  anchor,
284
329
  opts
@@ -286,19 +331,19 @@ function secondsClause(ir, anchor, opts) {
286
331
  }
287
332
  if (shape === "range") {
288
333
  const bounds = secondField.split("-");
289
- const num = seriesNumber(bounds, opts);
334
+ const num = seriesNumber();
290
335
  return "every second from " + num(bounds[0]) + through(opts) + num(bounds[1]) + " past the " + anchor;
291
336
  }
292
337
  if (shape === "single") {
293
338
  return "at " + getNumber(secondField, opts) + " " + pluralize(secondField, "second") + " past the " + anchor;
294
339
  }
295
340
  return strideFromSegments(
296
- ir.analyses.segments.second,
341
+ segmentsOf(ir, "second"),
297
342
  "second",
298
343
  anchor,
299
344
  opts
300
345
  ) ?? listPastThe(
301
- segmentWords(ir.analyses.segments.second, opts),
346
+ segmentWords(segmentsOf(ir, "second"), opts),
302
347
  "second",
303
348
  anchor,
304
349
  opts
@@ -315,15 +360,15 @@ function renderRangeOfMinutes(ir, plan, opts) {
315
360
  return minuteRangeLead(ir.pattern.minute, opts) + trailingQualifier(ir, opts);
316
361
  }
317
362
  function renderMultipleMinutes(ir, plan, opts) {
318
- const stride = strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts);
363
+ const stride = strideFromSegments(segmentsOf(ir, "minute"), "minute", "hour", opts);
319
364
  return (stride ?? listPastThe(segmentWords(
320
- ir.analyses.segments.minute,
365
+ segmentsOf(ir, "minute"),
321
366
  opts
322
367
  ), "minute", "hour", opts)) + trailingQualifier(ir, opts);
323
368
  }
324
369
  function renderMinuteFrequency(ir, plan, opts) {
325
370
  let phrase = stepCycle60(
326
- ir.analyses.segments.minute[0],
371
+ stepSegment(ir, "minute"),
327
372
  "minute",
328
373
  "hour",
329
374
  opts
@@ -332,9 +377,14 @@ function renderMinuteFrequency(ir, plan, opts) {
332
377
  const cadence = unevenHourCadence(ir, opts);
333
378
  phrase += cadence ? ", " + cadence : " during the " + hourTimesFromPlan(ir, plan.hours.times, false, opts) + " hours";
334
379
  } else if (plan.hours.kind === "window") {
335
- phrase += " " + hourWindow(plan.hours, opts);
380
+ phrase += " " + rangeWindow({
381
+ continuous: false,
382
+ from: plan.hours.from,
383
+ throughMinute: plan.hours.last,
384
+ to: plan.hours.to
385
+ }, opts);
336
386
  } else if (plan.hours.kind === "step") {
337
- phrase += " " + everyNthHour(ir.analyses.segments.hour[0], opts);
387
+ phrase += " " + everyNthHour(stepSegment(ir, "hour"), opts);
338
388
  }
339
389
  return phrase + trailingQualifier(ir, opts);
340
390
  }
@@ -352,15 +402,21 @@ function renderMinutesAcrossHours(ir, plan, opts) {
352
402
  }
353
403
  return "every minute during the " + hourTimesFromPlan(ir, plan.times, false, opts) + " hours" + trailingQualifier(ir, opts);
354
404
  }
355
- const lead = plan.form === "range" ? minuteRangeLead(ir.pattern.minute, opts) : (
356
- // The 'list' form is a minute list, which has segments; an offset/uneven
357
- // step enumerated to that list reads as a stride.
358
- strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts) ?? listPastThe(
359
- segmentWords(ir.analyses.segments.minute, opts),
360
- "minute",
361
- "hour",
362
- opts
363
- )
405
+ if (plan.form === "range") {
406
+ const lead2 = minuteRangeLead(ir.pattern.minute, opts);
407
+ if (cadence !== null) {
408
+ return lead2 + ", " + cadence + trailingQualifier(ir, opts);
409
+ }
410
+ if (singleHourFire(plan.times)) {
411
+ return lead2 + ", at " + hourTimesFromPlan(ir, plan.times, true, opts) + trailingQualifier(ir, opts);
412
+ }
413
+ return lead2 + " during the " + hourTimesFromPlan(ir, plan.times, false, opts) + " hours" + trailingQualifier(ir, opts);
414
+ }
415
+ const lead = strideFromSegments(segmentsOf(ir, "minute"), "minute", "hour", opts) ?? listPastThe(
416
+ segmentWords(segmentsOf(ir, "minute"), opts),
417
+ "minute",
418
+ "hour",
419
+ opts
364
420
  );
365
421
  if (cadence !== null) {
366
422
  return lead + ", " + cadence + trailingQualifier(ir, opts);
@@ -382,12 +438,12 @@ function everyNthHour(segment, opts) {
382
438
  return start === 0 ? base : base + " starting at " + getTime({ hour: start, minute: 0 }, opts);
383
439
  }
384
440
  function renderMinuteSpanAcrossHourStep(ir, plan, opts) {
385
- const segment = ir.analyses.segments.hour[0];
441
+ const segment = stepSegment(ir, "hour");
386
442
  if (plan.form === "wildcard") {
387
443
  return "every minute " + everyNthHour(segment, opts) + trailingQualifier(ir, opts);
388
444
  }
389
- const lead = plan.form === "list" ? strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts) ?? listPastThe(
390
- segmentWords(ir.analyses.segments.minute, opts),
445
+ const lead = plan.form === "list" ? strideFromSegments(segmentsOf(ir, "minute"), "minute", "hour", opts) ?? listPastThe(
446
+ segmentWords(segmentsOf(ir, "minute"), opts),
391
447
  "minute",
392
448
  "hour",
393
449
  opts
@@ -397,7 +453,7 @@ function renderMinuteSpanAcrossHourStep(ir, plan, opts) {
397
453
  }
398
454
  function minuteRangeLead(minuteField, opts) {
399
455
  const bounds = minuteField.split("-");
400
- const num = seriesNumber(bounds, opts);
456
+ const num = seriesNumber();
401
457
  return "every minute from " + num(bounds[0]) + through(opts) + num(bounds[1]) + " past the hour";
402
458
  }
403
459
  function renderEveryHour(ir, plan, opts) {
@@ -418,12 +474,12 @@ function rangeMinuteLead(ir, opts) {
418
474
  return "every hour";
419
475
  }
420
476
  return strideFromSegments(
421
- ir.analyses.segments.minute,
477
+ segmentsOf(ir, "minute"),
422
478
  "minute",
423
479
  "hour",
424
480
  opts
425
481
  ) ?? listPastThe(
426
- segmentWords(ir.analyses.segments.minute, opts),
482
+ segmentWords(segmentsOf(ir, "minute"), opts),
427
483
  "minute",
428
484
  "hour",
429
485
  opts
@@ -434,14 +490,28 @@ function renderHourStep(ir, plan, opts) {
434
490
  if (cadence !== null) {
435
491
  return cadence + trailingQualifier(ir, opts);
436
492
  }
437
- return stepHours(ir.analyses.segments.hour[0], opts) + trailingQualifier(ir, opts);
493
+ return stepHours(stepSegment(ir, "hour"), opts) + trailingQualifier(ir, opts);
438
494
  }
439
495
  function boundedWindow(plan) {
440
- const last = plan.minuteForm === "wildcard" ? plan.boundMinute ?? 0 : 0;
441
- return { from: plan.from, last, to: plan.to };
496
+ const continuous = plan.minuteForm === "wildcard";
497
+ const closeMinute = continuous ? plan.boundMinute ?? 0 : 0;
498
+ return { from: plan.from, closeMinute, to: plan.to, continuous };
499
+ }
500
+ function rangeWindow(window, opts) {
501
+ const { from, to, throughMinute, continuous } = window;
502
+ const open = "from " + getTime({ hour: from, minute: 0 }, opts);
503
+ if (opts.style.untilWindow && !opts.short && from !== to) {
504
+ return continuous ? open + " until " + getTime({ hour: (to + 1) % 24, minute: 0 }, opts) : open + through(opts) + getTime({ hour: to, minute: 0 }, opts);
505
+ }
506
+ return open + through(opts) + getTime({ hour: to, minute: throughMinute }, opts);
442
507
  }
443
508
  function hourWindow(window, opts) {
444
- return "from " + getTime({ hour: window.from, minute: 0 }, opts) + through(opts) + getTime({ hour: window.to, minute: window.last }, opts);
509
+ return rangeWindow({
510
+ continuous: window.continuous,
511
+ from: window.from,
512
+ throughMinute: window.closeMinute,
513
+ to: window.to
514
+ }, opts);
445
515
  }
446
516
  function renderClockTimes(ir, plan, opts) {
447
517
  if (ir.shapes.minute === "single") {
@@ -460,7 +530,10 @@ function renderClockTimes(ir, plan, opts) {
460
530
  plain
461
531
  }, opts);
462
532
  });
463
- return interpretDayQualifier(ir, opts) + "at " + joinList(times, opts);
533
+ return interpretDayQualifier(ir, opts) + "at " + joinList(times, opts) + dayUnionTrail(ir, opts);
534
+ }
535
+ function dayUnionTrail(ir, opts) {
536
+ return isDayUnion(ir, opts) ? dayUnionCondition(ir, opts) : "";
464
537
  }
465
538
  function renderCompactClockTimes(ir, plan, opts) {
466
539
  if (plan.fold) {
@@ -468,20 +541,20 @@ function renderCompactClockTimes(ir, plan, opts) {
468
541
  if (cadence2 !== null) {
469
542
  return cadence2;
470
543
  }
471
- const hasRange = ir.analyses.segments.hour.some(function range(segment) {
544
+ const hasRange = segmentsOf(ir, "hour").some(function range(segment) {
472
545
  return segment.kind === "range";
473
546
  });
474
547
  if (hasRange && !ir.analyses.clockSecond) {
475
548
  return foldedHourWindows(ir, plan, opts) + trailingQualifier(ir, opts);
476
549
  }
477
550
  const fold = { minute: plan.minute, second: ir.analyses.clockSecond };
478
- return interpretDayQualifier(ir, opts) + "at " + hourSegmentTimes(ir, fold, true, opts);
551
+ return interpretDayQualifier(ir, opts) + "at " + hourSegmentTimes(ir, fold, true, opts) + dayUnionTrail(ir, opts);
479
552
  }
480
553
  const minuteLead = (
481
554
  // The non-fold branch is a minute list, which has segments. An
482
555
  // offset/uneven step enumerated to that list reads as a stride.
483
- strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts) ?? listPastThe(
484
- segmentWords(ir.analyses.segments.minute, opts),
556
+ strideFromSegments(segmentsOf(ir, "minute"), "minute", "hour", opts) ?? listPastThe(
557
+ segmentWords(segmentsOf(ir, "minute"), opts),
485
558
  "minute",
486
559
  "hour",
487
560
  opts
@@ -494,26 +567,162 @@ function renderCompactClockTimes(ir, plan, opts) {
494
567
  function foldedHourWindows(ir, plan, opts) {
495
568
  const minute = plan.minute;
496
569
  const windows = [];
497
- const singles = [];
498
- ir.analyses.segments.hour.forEach(function classify(segment) {
570
+ const times = collectHourOutliers(ir).map(function time(hour) {
571
+ return getTime({ hour, minute }, opts);
572
+ });
573
+ segmentsOf(ir, "hour").forEach(function classify(segment) {
499
574
  if (segment.kind === "range") {
500
- windows.push("from " + getTime(
501
- { hour: segment.bounds[0], minute: 0 },
502
- opts
503
- ) + through(opts) + getTime({ hour: segment.bounds[1], minute }, opts));
504
- } else if (segment.kind === "step") {
505
- singles.push(...segment.fires);
506
- } else {
507
- singles.push(+segment.value);
575
+ windows.push(rangeWindow({
576
+ continuous: false,
577
+ from: +segment.bounds[0],
578
+ throughMinute: minute,
579
+ to: +segment.bounds[1]
580
+ }, opts));
508
581
  }
509
582
  });
510
- let phrase = rangeMinuteLead(ir, opts) + " " + joinList(windows, opts);
511
- if (singles.length) {
512
- phrase += " and at " + joinList(singles.map(function time(hour) {
513
- return getTime({ hour, minute }, opts);
514
- }), opts);
583
+ const phrase = rangeMinuteLead(ir, opts) + " " + joinList(windows, opts);
584
+ return phrase + outlierTail(times, opts);
585
+ }
586
+ function collectHourOutliers(ir) {
587
+ const hours = [];
588
+ segmentsOf(ir, "hour").forEach(function classify(segment) {
589
+ if (segment.kind === "step") {
590
+ hours.push(...segment.fires);
591
+ } else if (segment.kind !== "range") {
592
+ hours.push(+segment.value);
593
+ }
594
+ });
595
+ return hours;
596
+ }
597
+ function outlierTail(times, opts) {
598
+ if (!times.length) {
599
+ return "";
515
600
  }
516
- return phrase;
601
+ return " and at " + joinList(times, opts);
602
+ }
603
+ function isCadenceField(token) {
604
+ return token === "*" || token.startsWith("*/") && token.indexOf("-") === -1;
605
+ }
606
+ function leadingCadence(ir, opts) {
607
+ const { second, minute } = ir.pattern;
608
+ if (isCadenceField(second)) {
609
+ return { secondLead: true, text: secondsClause(ir, "minute", opts) };
610
+ }
611
+ if (second === "0" && isCadenceField(minute)) {
612
+ const text = minute === "*" ? "every minute" : (
613
+ // A clean minute step's first segment is a step segment.
614
+ stepCycle60(
615
+ stepSegment(ir, "minute"),
616
+ "minute",
617
+ "hour",
618
+ opts
619
+ )
620
+ );
621
+ return { secondLead: false, text };
622
+ }
623
+ return null;
624
+ }
625
+ function minuteConfinement(ir, opts) {
626
+ const minute = ir.pattern.minute;
627
+ if (minute === "*") {
628
+ return "";
629
+ }
630
+ if (isCadenceField(minute)) {
631
+ return " of every other minute";
632
+ }
633
+ const segments = segmentsOf(ir, "minute");
634
+ if (ir.shapes.minute === "single") {
635
+ return " during minute :" + pad(minute);
636
+ }
637
+ if (ir.shapes.minute === "range") {
638
+ const bounds = minute.split("-");
639
+ return " during minutes :" + pad(bounds[0]) + through(opts) + ":" + pad(bounds[1]);
640
+ }
641
+ const values = segmentWords(segments, opts).map(function colon(word) {
642
+ return ":" + pad(word);
643
+ });
644
+ return " during minutes " + joinList(values, opts);
645
+ }
646
+ function hourConfinement(ir, opts) {
647
+ const hour = ir.pattern.hour;
648
+ if (hour === "*") {
649
+ const minutePinned = ir.pattern.minute !== "*" && !isCadenceField(ir.pattern.minute);
650
+ return minutePinned ? " of every hour" : "";
651
+ }
652
+ if (isCadenceField(hour)) {
653
+ return hour === "*/2" ? " of every other hour" : "";
654
+ }
655
+ if (ir.shapes.hour === "single") {
656
+ const h = +hour;
657
+ if (ir.shapes.minute === "step") {
658
+ return " from " + getTime({ hour: h, minute: 0 }, opts) + " until " + getTime({ hour: (h + 1) % 24, minute: 0 }, opts);
659
+ }
660
+ if (ir.pattern.minute !== "*" && !isCadenceField(ir.pattern.minute)) {
661
+ return " at " + getTime({ hour: h, minute: 0 }, opts);
662
+ }
663
+ return " of the " + getTime({ hour: h, minute: 0 }, opts) + " hour";
664
+ }
665
+ if (ir.shapes.hour === "range") {
666
+ const bounds = hour.split("-");
667
+ return " " + rangeWindow({
668
+ continuous: ir.pattern.minute === "*",
669
+ from: +bounds[0],
670
+ throughMinute: 0,
671
+ to: +bounds[1]
672
+ }, opts);
673
+ }
674
+ return " during the " + hourSegmentTimes(ir, { minute: 0, second: null }, false, opts) + " hours";
675
+ }
676
+ function isContiguousHourRange(ir) {
677
+ return ir.shapes.hour === "range";
678
+ }
679
+ function confinableHour(ir) {
680
+ if (ir.shapes.hour !== "step") {
681
+ return true;
682
+ }
683
+ const segment = stepSegment(ir, "hour");
684
+ return ir.pattern.hour === "*/2" || segment.startToken.indexOf("-") !== -1;
685
+ }
686
+ function isMinuteStride(ir) {
687
+ if (ir.shapes.minute !== "list") {
688
+ return false;
689
+ }
690
+ const values = singleValues(segmentsOf(ir, "minute"));
691
+ return values !== null && arithmeticStep(values) !== null;
692
+ }
693
+ function confinementEligible(ir, lead) {
694
+ const { minute, hour } = ir.pattern;
695
+ const minuteStep = isCadenceField(minute) && minute !== "*";
696
+ if (!confinableHour(ir)) {
697
+ return false;
698
+ }
699
+ if (lead.secondLead) {
700
+ if (minuteStep) {
701
+ return minute === "*/2" && !isContiguousHourRange(ir);
702
+ }
703
+ if (isMinuteStride(ir) || ir.shapes.minute === "list" && ir.shapes.hour === "list") {
704
+ return false;
705
+ }
706
+ return true;
707
+ }
708
+ if (hour === "*/2") {
709
+ return true;
710
+ }
711
+ return ir.shapes.hour === "single" && minute === "*/2";
712
+ }
713
+ function confinement(ir, opts) {
714
+ if (!opts.style.untilWindow || opts.short) {
715
+ return null;
716
+ }
717
+ if (ir.pattern.minute === "*" && ir.pattern.hour === "*") {
718
+ return null;
719
+ }
720
+ const lead = leadingCadence(ir, opts);
721
+ if (!lead || !confinementEligible(ir, lead)) {
722
+ return null;
723
+ }
724
+ const minutePart = lead.secondLead ? minuteConfinement(ir, opts) : "";
725
+ return lead.text + minutePart + hourConfinement(ir, opts) + trailingQualifier(ir, opts);
517
726
  }
518
727
  var renderers = {
519
728
  clockTimes: renderClockTimes,
@@ -545,19 +754,9 @@ function renderStride(stride, opts) {
545
754
  if (start < interval && tiles) {
546
755
  return cadence + " from " + getNumber(start, opts) + " " + pluralize(start, unit) + " past the " + anchor;
547
756
  }
548
- const num = seriesNumber([start, last], opts);
757
+ const num = seriesNumber();
549
758
  return cadence + " from " + num(start) + through(opts) + num(last) + " " + pluralize(last, unit) + " past the " + anchor;
550
759
  }
551
- function singleValues(segments) {
552
- const values = [];
553
- for (const segment of segments) {
554
- if (segment.kind !== "single") {
555
- return null;
556
- }
557
- values.push(+segment.value);
558
- }
559
- return values;
560
- }
561
760
  function strideFromSegments(segments, unit, anchor, opts) {
562
761
  const values = singleValues(segments);
563
762
  const step = values && arithmeticStep(values);
@@ -606,9 +805,6 @@ function hourStrideCadence(stride, opts) {
606
805
  }
607
806
  return cadence + " from " + getTime({ hour: start, minute: 0 }, opts) + through(opts) + getTime({ hour: last, minute: 0 }, opts);
608
807
  }
609
- function offsetCleanStride(stride) {
610
- return stride.start < stride.interval && 24 % stride.interval === 0;
611
- }
612
808
  function unevenHourCadence(ir, opts) {
613
809
  const stride = hourStride(ir);
614
810
  if (!stride || offsetCleanStride(stride)) {
@@ -616,26 +812,8 @@ function unevenHourCadence(ir, opts) {
616
812
  }
617
813
  return hourStrideCadence(stride, opts);
618
814
  }
619
- function hourListStride(values) {
620
- if (values.length < 2) {
621
- return null;
622
- }
623
- const interval = values[1] - values[0];
624
- if (interval < 2) {
625
- return null;
626
- }
627
- for (let i = 2; i < values.length; i += 1) {
628
- if (values[i] - values[i - 1] !== interval) {
629
- return null;
630
- }
631
- }
632
- if (values[0] !== 0 && values.length < 5) {
633
- return null;
634
- }
635
- return { interval, last: values[values.length - 1], start: values[0] };
636
- }
637
815
  function hourStride(ir) {
638
- const segments = ir.analyses.segments.hour;
816
+ const segments = segmentsOf(ir, "hour");
639
817
  if (segments.length === 1 && segments[0].kind === "step") {
640
818
  const segment = segments[0];
641
819
  if (segment.fires.length < 2) {
@@ -672,9 +850,9 @@ function hourCadence(ir, minute, opts) {
672
850
  if (ir.pattern.second === "0" && fires <= maxClockTimes && offsetCleanStride(stride)) {
673
851
  return null;
674
852
  }
675
- const confinement = minute === 0 && subMinuteSecond(ir) && cleanStrideSegment(ir);
676
- if (confinement) {
677
- return secondsClause(ir, "minute", opts) + " for one minute " + everyNthHour(confinement, opts) + trailingQualifier(ir, opts);
853
+ const minuteZeroStride = minute === 0 && subMinuteSecond(ir) && cleanStrideSegment(ir);
854
+ if (minuteZeroStride) {
855
+ return secondsClause(ir, "minute", opts) + " for one minute " + everyNthHour(minuteZeroStride, opts) + trailingQualifier(ir, opts);
678
856
  }
679
857
  if (minute === 0 && ir.pattern.second === "0") {
680
858
  return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
@@ -682,7 +860,7 @@ function hourCadence(ir, minute, opts) {
682
860
  return hourCadenceLead(ir, minute, opts) + ", " + hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
683
861
  }
684
862
  function cleanStrideSegment(ir) {
685
- const segments = ir.analyses.segments.hour;
863
+ const segments = segmentsOf(ir, "hour");
686
864
  const segment = segments.length === 1 && segments[0];
687
865
  if (!segment || segment.kind !== "step" || segment.startToken.indexOf("-") !== -1 || !(segment.interval in stepOrdinals)) {
688
866
  return null;
@@ -690,32 +868,28 @@ function cleanStrideSegment(ir) {
690
868
  return segment;
691
869
  }
692
870
  function hasHourWindow(ir) {
693
- return ir.analyses.segments.hour.some(function range(segment) {
871
+ return segmentsOf(ir, "hour").some(function range(segment) {
694
872
  return segment.kind === "range";
695
873
  });
696
874
  }
697
875
  function hourRangeWindowTail(ir, opts) {
698
876
  const windows = [];
699
- const singles = [];
700
- ir.analyses.segments.hour.forEach(function classify(segment) {
877
+ const outlierHours = collectHourOutliers(ir);
878
+ segmentsOf(ir, "hour").forEach(function classify(segment) {
701
879
  if (segment.kind === "range") {
702
- windows.push("from " + getTime(
703
- { hour: +segment.bounds[0], minute: 0 },
704
- opts
705
- ) + through(opts) + getTime({ hour: +segment.bounds[1], minute: 0 }, opts));
706
- } else if (segment.kind === "step") {
707
- singles.push(...segment.fires);
708
- } else {
709
- singles.push(+segment.value);
880
+ windows.push(rangeWindow({
881
+ continuous: false,
882
+ from: +segment.bounds[0],
883
+ throughMinute: 0,
884
+ to: +segment.bounds[1]
885
+ }, opts));
710
886
  }
711
887
  });
712
- let phrase = "every hour " + joinList(windows, opts);
713
- if (singles.length) {
714
- phrase += " and at " + joinList(singles.map(function time(hour) {
715
- return getTime({ hour, minute: 0 }, opts);
716
- }), opts);
717
- }
718
- return phrase;
888
+ const phrase = "every hour " + joinList(windows, opts);
889
+ const times = outlierHours.map(function time(hour) {
890
+ return getTime({ hour, minute: 0 }, opts);
891
+ });
892
+ return phrase + outlierTail(times, opts);
719
893
  }
720
894
  function hourRangeCadence(ir, minute, opts) {
721
895
  if (minute !== 0 || !hasHourWindow(ir)) {
@@ -729,25 +903,29 @@ function hourRangeCadence(ir, minute, opts) {
729
903
  }
730
904
  return hourCadenceLead(ir, minute, opts) + ", " + hourRangeWindowTail(ir, opts) + trailingQualifier(ir, opts);
731
905
  }
732
- function seriesNumber(values, opts) {
733
- const anyBig = values.some(function big(v) {
734
- return +v > 10;
735
- });
906
+ function seriesNumber() {
736
907
  return function format(n) {
737
- return anyBig ? "" + n : getNumber(n, opts);
908
+ return "" + n;
909
+ };
910
+ }
911
+ function listNumber(count, opts) {
912
+ return count > 1 ? function asNumeral(n) {
913
+ return "" + n;
914
+ } : function spelled(n) {
915
+ return getNumber(n, opts);
738
916
  };
739
917
  }
740
918
  function numberWords(fires, opts) {
741
- return fires.map(seriesNumber(fires, opts));
919
+ return fires.map(listNumber(fires.length, opts));
742
920
  }
743
921
  function segmentWords(segments, opts) {
744
- const values = segments.flatMap(function collect(segment) {
922
+ const count = segments.reduce(function tally(sum, segment) {
745
923
  if (segment.kind === "range") {
746
- return segment.bounds;
924
+ return sum + 1;
747
925
  }
748
- return segment.kind === "step" ? segment.fires : [segment.value];
749
- });
750
- const num = seriesNumber(values, opts);
926
+ return sum + (segment.kind === "step" ? segment.fires.length : 1);
927
+ }, 0);
928
+ const num = listNumber(count, opts);
751
929
  return segments.flatMap(function word(segment) {
752
930
  if (segment.kind === "range") {
753
931
  return [num(segment.bounds[0]) + through(opts) + num(segment.bounds[1])];
@@ -779,6 +957,9 @@ function hourTimes(hours, opts) {
779
957
  });
780
958
  return joinList(times, opts);
781
959
  }
960
+ function singleHourFire(times) {
961
+ return times.kind === "fires" && times.fires.length === 1;
962
+ }
782
963
  function hourTimesFromPlan(ir, times, atContext, opts) {
783
964
  if (times.kind === "fires") {
784
965
  return hourTimes(times.fires, opts);
@@ -793,7 +974,7 @@ function segmentHours(segment) {
793
974
  }
794
975
  function hourSegmentTimes(ir, fold, atContext, opts) {
795
976
  const { minute, second } = fold;
796
- const segments = ir.analyses.segments.hour;
977
+ const segments = segmentsOf(ir, "hour");
797
978
  const plain = mixedTwelve(segments.flatMap(function entries(segment) {
798
979
  return segmentHours(segment).map(function entry(hour) {
799
980
  return { hour: +hour, minute, second };
@@ -826,28 +1007,47 @@ function disambiguateTimes(pieces, segments, atContext) {
826
1007
  return index === 0 ? piece : "at " + piece;
827
1008
  });
828
1009
  }
829
- function joinList(items, opts) {
1010
+ function joinWith(items, conjunction, opts) {
830
1011
  if (items.length <= 1) {
831
1012
  return items.join("");
832
1013
  }
833
1014
  if (items.length === 2) {
834
- return items[0] + " and " + items[1];
1015
+ return items[0] + conjunction + items[1];
835
1016
  }
836
- const and = opts.style.serialComma ? ", and " : " and ";
837
- return items.slice(0, -1).join(", ") + and + items[items.length - 1];
1017
+ const tail = opts.style.serialComma ? "," + conjunction : conjunction;
1018
+ return items.slice(0, -1).join(", ") + tail + items[items.length - 1];
1019
+ }
1020
+ function joinList(items, opts) {
1021
+ return joinWith(items, " and ", opts);
1022
+ }
1023
+ function joinOr(items, opts) {
1024
+ return joinWith(items, " or ", opts);
838
1025
  }
839
- var trailingWords = { all: "", month: "in ", stepDate: "on ", weekday: "on " };
1026
+ var trailingWords = {
1027
+ all: "",
1028
+ month: "in ",
1029
+ recurringWeekday: true,
1030
+ stepDate: "on ",
1031
+ weekday: "on "
1032
+ };
840
1033
  var leadingWords = {
841
1034
  all: "every day",
842
1035
  month: "every day in ",
1036
+ recurringWeekday: false,
843
1037
  stepDate: "",
844
1038
  weekday: "every "
845
1039
  };
846
1040
  function trailingQualifier(ir, opts) {
1041
+ if (isDayUnion(ir, opts)) {
1042
+ return dayUnionCondition(ir, opts);
1043
+ }
847
1044
  const phrase = dayQualifier(ir, trailingWords, opts);
848
1045
  return phrase && " " + phrase;
849
1046
  }
850
1047
  function interpretDayQualifier(ir, opts) {
1048
+ if (isDayUnion(ir, opts)) {
1049
+ return "";
1050
+ }
851
1051
  return dayQualifier(ir, leadingWords, opts) + " ";
852
1052
  }
853
1053
  function dayQualifier(ir, words, opts) {
@@ -859,7 +1059,11 @@ function dayQualifier(ir, words, opts) {
859
1059
  return datePhrase(ir, words, opts);
860
1060
  }
861
1061
  if (pattern.weekday !== "*") {
862
- const weekdays = quartzWeekdayPhrase(pattern.weekday, opts) || words.weekday + weekdayPhrase(ir, opts);
1062
+ const quartzWeekday = quartzWeekdayPhrase(pattern.weekday, opts);
1063
+ if (quartzWeekday) {
1064
+ return monthScopeForRecurrence(quartzWeekday, ir, opts);
1065
+ }
1066
+ const weekdays = words.weekday + weekdayPhrase(ir, words.recurringWeekday, opts);
863
1067
  return weekdays + monthScope(ir, opts);
864
1068
  }
865
1069
  if (pattern.month !== "*") {
@@ -871,10 +1075,14 @@ function datePhrase(ir, words, opts) {
871
1075
  const pattern = ir.pattern;
872
1076
  const quartzDate = quartzDatePhrase(pattern.date, opts);
873
1077
  if (quartzDate) {
874
- return quartzDate + monthScope(ir, opts);
1078
+ return monthScopeForRecurrence(quartzDate, ir, opts);
875
1079
  }
876
1080
  if (isOpenStep(pattern.date)) {
877
- return words.stepDate + stepDates(pattern.date) + monthScope(ir, opts);
1081
+ return monthScopeForRecurrence(
1082
+ words.stepDate + stepDates(pattern.date),
1083
+ ir,
1084
+ opts
1085
+ );
878
1086
  }
879
1087
  if (pattern.month !== "*" && !monthFoldsIntoDate(ir)) {
880
1088
  return "on the " + dateOrdinals(ir, opts) + monthScope(ir, opts);
@@ -886,13 +1094,88 @@ function datePhrase(ir, words, opts) {
886
1094
  }
887
1095
  function monthFoldsIntoDate(ir) {
888
1096
  return !oddEvenMonth(ir.pattern.month) && // Reached only with a restricted month, which has segments.
889
- ir.analyses.segments.month.every(function flat(segment) {
1097
+ segmentsOf(ir, "month").every(function flat(segment) {
890
1098
  return segment.kind !== "range";
891
1099
  });
892
1100
  }
1101
+ function isDayUnion(ir, opts) {
1102
+ return ir.pattern.date !== "*" && ir.pattern.weekday !== "*" && !!opts.style.untilWindow && !opts.short;
1103
+ }
1104
+ function dayUnionCondition(ir, opts) {
1105
+ const pieces = [
1106
+ ...dayUnionDatePieces(ir, opts),
1107
+ ...dayUnionWeekdayPieces(ir, opts)
1108
+ ];
1109
+ return " whenever the day is " + joinOr(pieces, opts);
1110
+ }
1111
+ function dayUnionMonthLead(ir, opts) {
1112
+ if (ir.pattern.month === "*") {
1113
+ return "";
1114
+ }
1115
+ return "in " + monthName(ir, opts) + " ";
1116
+ }
1117
+ function dayUnionDatePieces(ir, opts) {
1118
+ const dateField = ir.pattern.date;
1119
+ const quartz = quartzDatePhrase(dateField, opts);
1120
+ if (quartz) {
1121
+ return [quartz.replace(/^on /, "")];
1122
+ }
1123
+ const oddEven = oddEvenDay(dateField);
1124
+ if (oddEven) {
1125
+ return [oddEven];
1126
+ }
1127
+ const pieces = [];
1128
+ segmentsOf(ir, "date").forEach(function expand(segment) {
1129
+ if (segment.kind === "range") {
1130
+ pieces.push("from the " + getOrdinal(segment.bounds[0]) + through(opts) + "the " + getOrdinal(segment.bounds[1]));
1131
+ } else if (segment.kind === "step") {
1132
+ segment.fires.forEach(function fire(value) {
1133
+ pieces.push("the " + getOrdinal(value));
1134
+ });
1135
+ } else {
1136
+ pieces.push("the " + getOrdinal(segment.value));
1137
+ }
1138
+ });
1139
+ return pieces;
1140
+ }
1141
+ function dayUnionWeekdayPieces(ir, opts) {
1142
+ const weekdayField = ir.pattern.weekday;
1143
+ const quartz = quartzWeekdayPhrase(weekdayField, opts);
1144
+ if (quartz) {
1145
+ return [quartz.replace(/^on /, "")];
1146
+ }
1147
+ const pieces = [];
1148
+ segmentsOf(ir, "weekday").forEach(function expand(segment) {
1149
+ if (segment.kind === "range" && segment.bounds[0] === "1" && segment.bounds[1] === "5") {
1150
+ pieces.push("a weekday");
1151
+ } else if (segment.kind === "range") {
1152
+ pieces.push("a " + getWeekday(segment.bounds[0], opts) + through(opts) + "a " + getWeekday(segment.bounds[1], opts));
1153
+ } else if (segment.kind === "step") {
1154
+ segment.fires.forEach(function fire(value) {
1155
+ pieces.push("a " + getWeekday(value, opts));
1156
+ });
1157
+ } else {
1158
+ pieces.push("a " + getWeekday(segment.value, opts));
1159
+ }
1160
+ });
1161
+ return pieces;
1162
+ }
1163
+ function oddEvenDay(dateField) {
1164
+ if (!isOpenStep(dateField)) {
1165
+ return null;
1166
+ }
1167
+ const [start, step] = dateField.split("/");
1168
+ if (+step !== 2) {
1169
+ return null;
1170
+ }
1171
+ if (start === "*" || start === "1") {
1172
+ return "an odd-numbered day";
1173
+ }
1174
+ return start === "2" ? "an even-numbered day" : null;
1175
+ }
893
1176
  function dateOrWeekday(ir, opts) {
894
1177
  const pattern = ir.pattern;
895
- const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) || "on " + weekdayPhrase(ir, opts);
1178
+ const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) || "on " + weekdayPhrase(ir, false, opts);
896
1179
  if (pattern.month !== "*" && monthFoldsIntoDate(ir) && !quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
897
1180
  return "on " + monthDatePhrase(ir, opts) + " or " + weekdayPart + " in " + monthName(ir, opts);
898
1181
  }
@@ -943,10 +1226,13 @@ function quartzWeekdayPhrase(weekdayField, opts) {
943
1226
  function monthDatePhrase(ir, opts) {
944
1227
  const month = monthName(ir, opts);
945
1228
  const days = renderSegments(
946
- ir.analyses.segments.date,
1229
+ segmentsOf(ir, "date"),
947
1230
  opts.style.ordinals ? getOrdinal : cardinalDay,
948
1231
  opts
949
1232
  );
1233
+ if (opts.style.dayFirst && ir.shapes.date === "single" && ir.shapes.month !== "single") {
1234
+ return "the " + getOrdinal(ir.pattern.date) + " of " + month;
1235
+ }
950
1236
  return opts.style.dayFirst ? days + " " + month : month + " " + days;
951
1237
  }
952
1238
  function cardinalDay(value) {
@@ -958,6 +1244,19 @@ function monthScope(ir, opts) {
958
1244
  }
959
1245
  return " in " + monthName(ir, opts);
960
1246
  }
1247
+ function monthScopeForRecurrence(phrase, ir, opts) {
1248
+ if (ir.pattern.month === "*") {
1249
+ return phrase;
1250
+ }
1251
+ const carriesRecurrence = phrase.indexOf(" of the month") !== -1;
1252
+ if (carriesRecurrence && ir.shapes.month === "range") {
1253
+ return phrase.replace(" of the month", " of each month") + " from " + monthName(ir, opts);
1254
+ }
1255
+ if (carriesRecurrence && (ir.shapes.month === "single" || ir.shapes.month === "step")) {
1256
+ return phrase.replace(" of the month", "") + " in " + monthName(ir, opts);
1257
+ }
1258
+ return phrase + " in " + monthName(ir, opts);
1259
+ }
961
1260
  function stepDates(dateField) {
962
1261
  const parts = dateField.split("/");
963
1262
  const interval = +parts[1];
@@ -970,14 +1269,14 @@ function stepDates(dateField) {
970
1269
  return phrase;
971
1270
  }
972
1271
  function dateOrdinals(ir, opts) {
973
- return renderSegments(ir.analyses.segments.date, getOrdinal, opts);
1272
+ return renderSegments(segmentsOf(ir, "date"), getOrdinal, opts);
974
1273
  }
975
1274
  function monthName(ir, opts) {
976
1275
  const oddEven = oddEvenMonth(ir.pattern.month);
977
1276
  if (oddEven) {
978
1277
  return oddEven;
979
1278
  }
980
- return renderSegments(ir.analyses.segments.month, function name(value) {
1279
+ return renderSegments(segmentsOf(ir, "month"), function name(value) {
981
1280
  return getMonth(value, opts);
982
1281
  }, opts);
983
1282
  }
@@ -994,11 +1293,21 @@ function oddEvenMonth(monthField) {
994
1293
  }
995
1294
  return start === "2" ? "every even-numbered month" : null;
996
1295
  }
997
- function weekdayPhrase(ir, opts) {
998
- const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday);
999
- return renderSegments(segments, function name(value) {
1296
+ function weekdayPhrase(ir, recurring, opts) {
1297
+ const segments = orderWeekdaysForDisplay(segmentsOf(ir, "weekday"));
1298
+ const hasRange = segments.some(function range(segment) {
1299
+ return segment.kind === "range";
1300
+ });
1301
+ const name = recurring && !hasRange ? function plural(value) {
1302
+ return pluralWeekday(value, opts);
1303
+ } : function singular(value) {
1000
1304
  return getWeekday(value, opts);
1001
- }, opts);
1305
+ };
1306
+ return renderSegments(segments, name, opts);
1307
+ }
1308
+ function pluralWeekday(value, opts) {
1309
+ const name = getWeekday(value, opts);
1310
+ return opts.short ? name : name + "s";
1002
1311
  }
1003
1312
  function renderSegments(segments, word, opts) {
1004
1313
  const pieces = [];
@@ -1013,16 +1322,13 @@ function renderSegments(segments, word, opts) {
1013
1322
  });
1014
1323
  return joinList(pieces, opts);
1015
1324
  }
1016
- function isOpenStep(field) {
1017
- return field.indexOf("/") !== -1 && field.indexOf("-") === -1 && field.indexOf(",") === -1;
1018
- }
1019
1325
  function applyYear(description, ir, opts) {
1020
1326
  const yearField = ir.pattern.year;
1021
1327
  if (yearField === "*") {
1022
1328
  return description;
1023
1329
  }
1024
1330
  if (yearField.indexOf("/") !== -1) {
1025
- return description + " " + stepYears(yearField, opts);
1331
+ return description + ", " + stepYears(yearField, opts);
1026
1332
  }
1027
1333
  const label = yearLabel(yearField, opts);
1028
1334
  if (yearField.indexOf("-") === -1 && yearField.indexOf(",") === -1 && ir.pattern.date !== "*" && description.indexOf(" at ") !== -1) {
@@ -1035,6 +1341,9 @@ function yearLabel(yearField, opts) {
1035
1341
  if (yearField.indexOf(",") !== -1) {
1036
1342
  return joinList(yearField.split(","), opts);
1037
1343
  }
1344
+ if (yearField.indexOf("-") !== -1) {
1345
+ return yearField.split("-").join(through(opts));
1346
+ }
1038
1347
  return yearField;
1039
1348
  }
1040
1349
  function stepYears(yearField, opts) {
@@ -1044,7 +1353,7 @@ function stepYears(yearField, opts) {
1044
1353
  if (interval <= 1) {
1045
1354
  return "every year";
1046
1355
  }
1047
- let phrase = "every " + getNumber(interval, opts) + " years";
1356
+ let phrase = interval === 2 ? "every other year" : "every " + getNumber(interval, opts) + " years";
1048
1357
  if (start !== "*" && start !== "0") {
1049
1358
  phrase += " from " + start;
1050
1359
  }