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.js CHANGED
@@ -34,6 +34,48 @@ function orderWeekdaysForDisplay(segments) {
34
34
  return pair[0];
35
35
  });
36
36
  }
37
+ function segmentsOf(ir, field) {
38
+ return ir.analyses.segments[field] ?? [];
39
+ }
40
+ function stepSegment(ir, field) {
41
+ return segmentsOf(ir, field)[0];
42
+ }
43
+ function singleValues(segments) {
44
+ const values = [];
45
+ for (const segment of segments) {
46
+ if (segment.kind !== "single") {
47
+ return null;
48
+ }
49
+ values.push(+segment.value);
50
+ }
51
+ return values;
52
+ }
53
+ function offsetCleanStride(stride) {
54
+ return stride.start < stride.interval && 24 % stride.interval === 0;
55
+ }
56
+ function hourListStride(values) {
57
+ if (values.length < 2) {
58
+ return null;
59
+ }
60
+ const interval = values[1] - values[0];
61
+ if (interval < 2) {
62
+ return null;
63
+ }
64
+ for (let i = 2; i < values.length; i += 1) {
65
+ if (values[i] - values[i - 1] !== interval) {
66
+ return null;
67
+ }
68
+ }
69
+ if (values[0] !== 0 && values.length < 5) {
70
+ return null;
71
+ }
72
+ return { interval, last: values[values.length - 1], start: values[0] };
73
+ }
74
+
75
+ // src/core/shapes.ts
76
+ function isOpenStep(field) {
77
+ return field.indexOf("/") !== -1 && field.indexOf("-") === -1 && field.indexOf(",") === -1;
78
+ }
37
79
 
38
80
  // src/core/specs.ts
39
81
  var maxClockTimes = 6;
@@ -78,7 +120,8 @@ var dialects = {
78
120
  pm: "p.m.",
79
121
  sep: ":",
80
122
  serialComma: true,
81
- through: " through "
123
+ through: " through ",
124
+ untilWindow: true
82
125
  },
83
126
  house: {
84
127
  am: "AM",
@@ -95,7 +138,7 @@ var dialects = {
95
138
  };
96
139
  function resolveDialect(dialect) {
97
140
  if (typeof dialect === "object" && dialect !== null) {
98
- return { ...dialects.us, ...dialect };
141
+ return { ...dialects.us, untilWindow: false, ...dialect };
99
142
  }
100
143
  const name = dialect === "uk" ? "gb" : dialect;
101
144
  return dialects[name] || dialects.us;
@@ -167,7 +210,9 @@ function normalizeOptions(options) {
167
210
  };
168
211
  }
169
212
  function describe(ir, opts) {
170
- return applyYear(render(ir, ir.plan, opts), ir, opts);
213
+ const body = confinement(ir, opts) ?? render(ir, ir.plan, opts);
214
+ const lead = isDayUnion(ir, opts) ? dayUnionMonthLead(ir, opts) : "";
215
+ return applyYear(lead + body, ir, opts);
171
216
  }
172
217
  function render(ir, plan, opts) {
173
218
  const renderer = renderers[plan.kind];
@@ -252,7 +297,7 @@ function secondsClause(ir, anchor, opts) {
252
297
  }
253
298
  if (shape === "step") {
254
299
  return stepCycle60(
255
- ir.analyses.segments.second[0],
300
+ stepSegment(ir, "second"),
256
301
  "second",
257
302
  anchor,
258
303
  opts
@@ -260,19 +305,19 @@ function secondsClause(ir, anchor, opts) {
260
305
  }
261
306
  if (shape === "range") {
262
307
  const bounds = secondField.split("-");
263
- const num = seriesNumber(bounds, opts);
308
+ const num = seriesNumber();
264
309
  return "every second from " + num(bounds[0]) + through(opts) + num(bounds[1]) + " past the " + anchor;
265
310
  }
266
311
  if (shape === "single") {
267
312
  return "at " + getNumber(secondField, opts) + " " + pluralize(secondField, "second") + " past the " + anchor;
268
313
  }
269
314
  return strideFromSegments(
270
- ir.analyses.segments.second,
315
+ segmentsOf(ir, "second"),
271
316
  "second",
272
317
  anchor,
273
318
  opts
274
319
  ) ?? listPastThe(
275
- segmentWords(ir.analyses.segments.second, opts),
320
+ segmentWords(segmentsOf(ir, "second"), opts),
276
321
  "second",
277
322
  anchor,
278
323
  opts
@@ -289,15 +334,15 @@ function renderRangeOfMinutes(ir, plan, opts) {
289
334
  return minuteRangeLead(ir.pattern.minute, opts) + trailingQualifier(ir, opts);
290
335
  }
291
336
  function renderMultipleMinutes(ir, plan, opts) {
292
- const stride = strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts);
337
+ const stride = strideFromSegments(segmentsOf(ir, "minute"), "minute", "hour", opts);
293
338
  return (stride ?? listPastThe(segmentWords(
294
- ir.analyses.segments.minute,
339
+ segmentsOf(ir, "minute"),
295
340
  opts
296
341
  ), "minute", "hour", opts)) + trailingQualifier(ir, opts);
297
342
  }
298
343
  function renderMinuteFrequency(ir, plan, opts) {
299
344
  let phrase = stepCycle60(
300
- ir.analyses.segments.minute[0],
345
+ stepSegment(ir, "minute"),
301
346
  "minute",
302
347
  "hour",
303
348
  opts
@@ -306,9 +351,14 @@ function renderMinuteFrequency(ir, plan, opts) {
306
351
  const cadence = unevenHourCadence(ir, opts);
307
352
  phrase += cadence ? ", " + cadence : " during the " + hourTimesFromPlan(ir, plan.hours.times, false, opts) + " hours";
308
353
  } else if (plan.hours.kind === "window") {
309
- phrase += " " + hourWindow(plan.hours, opts);
354
+ phrase += " " + rangeWindow({
355
+ continuous: false,
356
+ from: plan.hours.from,
357
+ throughMinute: plan.hours.last,
358
+ to: plan.hours.to
359
+ }, opts);
310
360
  } else if (plan.hours.kind === "step") {
311
- phrase += " " + everyNthHour(ir.analyses.segments.hour[0], opts);
361
+ phrase += " " + everyNthHour(stepSegment(ir, "hour"), opts);
312
362
  }
313
363
  return phrase + trailingQualifier(ir, opts);
314
364
  }
@@ -326,15 +376,21 @@ function renderMinutesAcrossHours(ir, plan, opts) {
326
376
  }
327
377
  return "every minute during the " + hourTimesFromPlan(ir, plan.times, false, opts) + " hours" + trailingQualifier(ir, opts);
328
378
  }
329
- const lead = plan.form === "range" ? minuteRangeLead(ir.pattern.minute, opts) : (
330
- // The 'list' form is a minute list, which has segments; an offset/uneven
331
- // step enumerated to that list reads as a stride.
332
- strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts) ?? listPastThe(
333
- segmentWords(ir.analyses.segments.minute, opts),
334
- "minute",
335
- "hour",
336
- opts
337
- )
379
+ if (plan.form === "range") {
380
+ const lead2 = minuteRangeLead(ir.pattern.minute, opts);
381
+ if (cadence !== null) {
382
+ return lead2 + ", " + cadence + trailingQualifier(ir, opts);
383
+ }
384
+ if (singleHourFire(plan.times)) {
385
+ return lead2 + ", at " + hourTimesFromPlan(ir, plan.times, true, opts) + trailingQualifier(ir, opts);
386
+ }
387
+ return lead2 + " during the " + hourTimesFromPlan(ir, plan.times, false, opts) + " hours" + trailingQualifier(ir, opts);
388
+ }
389
+ const lead = strideFromSegments(segmentsOf(ir, "minute"), "minute", "hour", opts) ?? listPastThe(
390
+ segmentWords(segmentsOf(ir, "minute"), opts),
391
+ "minute",
392
+ "hour",
393
+ opts
338
394
  );
339
395
  if (cadence !== null) {
340
396
  return lead + ", " + cadence + trailingQualifier(ir, opts);
@@ -356,12 +412,12 @@ function everyNthHour(segment, opts) {
356
412
  return start === 0 ? base : base + " starting at " + getTime({ hour: start, minute: 0 }, opts);
357
413
  }
358
414
  function renderMinuteSpanAcrossHourStep(ir, plan, opts) {
359
- const segment = ir.analyses.segments.hour[0];
415
+ const segment = stepSegment(ir, "hour");
360
416
  if (plan.form === "wildcard") {
361
417
  return "every minute " + everyNthHour(segment, opts) + trailingQualifier(ir, opts);
362
418
  }
363
- const lead = plan.form === "list" ? strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts) ?? listPastThe(
364
- segmentWords(ir.analyses.segments.minute, opts),
419
+ const lead = plan.form === "list" ? strideFromSegments(segmentsOf(ir, "minute"), "minute", "hour", opts) ?? listPastThe(
420
+ segmentWords(segmentsOf(ir, "minute"), opts),
365
421
  "minute",
366
422
  "hour",
367
423
  opts
@@ -371,7 +427,7 @@ function renderMinuteSpanAcrossHourStep(ir, plan, opts) {
371
427
  }
372
428
  function minuteRangeLead(minuteField, opts) {
373
429
  const bounds = minuteField.split("-");
374
- const num = seriesNumber(bounds, opts);
430
+ const num = seriesNumber();
375
431
  return "every minute from " + num(bounds[0]) + through(opts) + num(bounds[1]) + " past the hour";
376
432
  }
377
433
  function renderEveryHour(ir, plan, opts) {
@@ -392,12 +448,12 @@ function rangeMinuteLead(ir, opts) {
392
448
  return "every hour";
393
449
  }
394
450
  return strideFromSegments(
395
- ir.analyses.segments.minute,
451
+ segmentsOf(ir, "minute"),
396
452
  "minute",
397
453
  "hour",
398
454
  opts
399
455
  ) ?? listPastThe(
400
- segmentWords(ir.analyses.segments.minute, opts),
456
+ segmentWords(segmentsOf(ir, "minute"), opts),
401
457
  "minute",
402
458
  "hour",
403
459
  opts
@@ -408,14 +464,28 @@ function renderHourStep(ir, plan, opts) {
408
464
  if (cadence !== null) {
409
465
  return cadence + trailingQualifier(ir, opts);
410
466
  }
411
- return stepHours(ir.analyses.segments.hour[0], opts) + trailingQualifier(ir, opts);
467
+ return stepHours(stepSegment(ir, "hour"), opts) + trailingQualifier(ir, opts);
412
468
  }
413
469
  function boundedWindow(plan) {
414
- const last = plan.minuteForm === "wildcard" ? plan.boundMinute ?? 0 : 0;
415
- return { from: plan.from, last, to: plan.to };
470
+ const continuous = plan.minuteForm === "wildcard";
471
+ const closeMinute = continuous ? plan.boundMinute ?? 0 : 0;
472
+ return { from: plan.from, closeMinute, to: plan.to, continuous };
473
+ }
474
+ function rangeWindow(window, opts) {
475
+ const { from, to, throughMinute, continuous } = window;
476
+ const open = "from " + getTime({ hour: from, minute: 0 }, opts);
477
+ if (opts.style.untilWindow && !opts.short && from !== to) {
478
+ return continuous ? open + " until " + getTime({ hour: (to + 1) % 24, minute: 0 }, opts) : open + through(opts) + getTime({ hour: to, minute: 0 }, opts);
479
+ }
480
+ return open + through(opts) + getTime({ hour: to, minute: throughMinute }, opts);
416
481
  }
417
482
  function hourWindow(window, opts) {
418
- return "from " + getTime({ hour: window.from, minute: 0 }, opts) + through(opts) + getTime({ hour: window.to, minute: window.last }, opts);
483
+ return rangeWindow({
484
+ continuous: window.continuous,
485
+ from: window.from,
486
+ throughMinute: window.closeMinute,
487
+ to: window.to
488
+ }, opts);
419
489
  }
420
490
  function renderClockTimes(ir, plan, opts) {
421
491
  if (ir.shapes.minute === "single") {
@@ -434,7 +504,10 @@ function renderClockTimes(ir, plan, opts) {
434
504
  plain
435
505
  }, opts);
436
506
  });
437
- return interpretDayQualifier(ir, opts) + "at " + joinList(times, opts);
507
+ return interpretDayQualifier(ir, opts) + "at " + joinList(times, opts) + dayUnionTrail(ir, opts);
508
+ }
509
+ function dayUnionTrail(ir, opts) {
510
+ return isDayUnion(ir, opts) ? dayUnionCondition(ir, opts) : "";
438
511
  }
439
512
  function renderCompactClockTimes(ir, plan, opts) {
440
513
  if (plan.fold) {
@@ -442,20 +515,20 @@ function renderCompactClockTimes(ir, plan, opts) {
442
515
  if (cadence2 !== null) {
443
516
  return cadence2;
444
517
  }
445
- const hasRange = ir.analyses.segments.hour.some(function range(segment) {
518
+ const hasRange = segmentsOf(ir, "hour").some(function range(segment) {
446
519
  return segment.kind === "range";
447
520
  });
448
521
  if (hasRange && !ir.analyses.clockSecond) {
449
522
  return foldedHourWindows(ir, plan, opts) + trailingQualifier(ir, opts);
450
523
  }
451
524
  const fold = { minute: plan.minute, second: ir.analyses.clockSecond };
452
- return interpretDayQualifier(ir, opts) + "at " + hourSegmentTimes(ir, fold, true, opts);
525
+ return interpretDayQualifier(ir, opts) + "at " + hourSegmentTimes(ir, fold, true, opts) + dayUnionTrail(ir, opts);
453
526
  }
454
527
  const minuteLead = (
455
528
  // The non-fold branch is a minute list, which has segments. An
456
529
  // offset/uneven step enumerated to that list reads as a stride.
457
- strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts) ?? listPastThe(
458
- segmentWords(ir.analyses.segments.minute, opts),
530
+ strideFromSegments(segmentsOf(ir, "minute"), "minute", "hour", opts) ?? listPastThe(
531
+ segmentWords(segmentsOf(ir, "minute"), opts),
459
532
  "minute",
460
533
  "hour",
461
534
  opts
@@ -468,26 +541,162 @@ function renderCompactClockTimes(ir, plan, opts) {
468
541
  function foldedHourWindows(ir, plan, opts) {
469
542
  const minute = plan.minute;
470
543
  const windows = [];
471
- const singles = [];
472
- ir.analyses.segments.hour.forEach(function classify(segment) {
544
+ const times = collectHourOutliers(ir).map(function time(hour) {
545
+ return getTime({ hour, minute }, opts);
546
+ });
547
+ segmentsOf(ir, "hour").forEach(function classify(segment) {
473
548
  if (segment.kind === "range") {
474
- windows.push("from " + getTime(
475
- { hour: segment.bounds[0], minute: 0 },
476
- opts
477
- ) + through(opts) + getTime({ hour: segment.bounds[1], minute }, opts));
478
- } else if (segment.kind === "step") {
479
- singles.push(...segment.fires);
480
- } else {
481
- singles.push(+segment.value);
549
+ windows.push(rangeWindow({
550
+ continuous: false,
551
+ from: +segment.bounds[0],
552
+ throughMinute: minute,
553
+ to: +segment.bounds[1]
554
+ }, opts));
482
555
  }
483
556
  });
484
- let phrase = rangeMinuteLead(ir, opts) + " " + joinList(windows, opts);
485
- if (singles.length) {
486
- phrase += " and at " + joinList(singles.map(function time(hour) {
487
- return getTime({ hour, minute }, opts);
488
- }), opts);
557
+ const phrase = rangeMinuteLead(ir, opts) + " " + joinList(windows, opts);
558
+ return phrase + outlierTail(times, opts);
559
+ }
560
+ function collectHourOutliers(ir) {
561
+ const hours = [];
562
+ segmentsOf(ir, "hour").forEach(function classify(segment) {
563
+ if (segment.kind === "step") {
564
+ hours.push(...segment.fires);
565
+ } else if (segment.kind !== "range") {
566
+ hours.push(+segment.value);
567
+ }
568
+ });
569
+ return hours;
570
+ }
571
+ function outlierTail(times, opts) {
572
+ if (!times.length) {
573
+ return "";
489
574
  }
490
- return phrase;
575
+ return " and at " + joinList(times, opts);
576
+ }
577
+ function isCadenceField(token) {
578
+ return token === "*" || token.startsWith("*/") && token.indexOf("-") === -1;
579
+ }
580
+ function leadingCadence(ir, opts) {
581
+ const { second, minute } = ir.pattern;
582
+ if (isCadenceField(second)) {
583
+ return { secondLead: true, text: secondsClause(ir, "minute", opts) };
584
+ }
585
+ if (second === "0" && isCadenceField(minute)) {
586
+ const text = minute === "*" ? "every minute" : (
587
+ // A clean minute step's first segment is a step segment.
588
+ stepCycle60(
589
+ stepSegment(ir, "minute"),
590
+ "minute",
591
+ "hour",
592
+ opts
593
+ )
594
+ );
595
+ return { secondLead: false, text };
596
+ }
597
+ return null;
598
+ }
599
+ function minuteConfinement(ir, opts) {
600
+ const minute = ir.pattern.minute;
601
+ if (minute === "*") {
602
+ return "";
603
+ }
604
+ if (isCadenceField(minute)) {
605
+ return " of every other minute";
606
+ }
607
+ const segments = segmentsOf(ir, "minute");
608
+ if (ir.shapes.minute === "single") {
609
+ return " during minute :" + pad(minute);
610
+ }
611
+ if (ir.shapes.minute === "range") {
612
+ const bounds = minute.split("-");
613
+ return " during minutes :" + pad(bounds[0]) + through(opts) + ":" + pad(bounds[1]);
614
+ }
615
+ const values = segmentWords(segments, opts).map(function colon(word) {
616
+ return ":" + pad(word);
617
+ });
618
+ return " during minutes " + joinList(values, opts);
619
+ }
620
+ function hourConfinement(ir, opts) {
621
+ const hour = ir.pattern.hour;
622
+ if (hour === "*") {
623
+ const minutePinned = ir.pattern.minute !== "*" && !isCadenceField(ir.pattern.minute);
624
+ return minutePinned ? " of every hour" : "";
625
+ }
626
+ if (isCadenceField(hour)) {
627
+ return hour === "*/2" ? " of every other hour" : "";
628
+ }
629
+ if (ir.shapes.hour === "single") {
630
+ const h = +hour;
631
+ if (ir.shapes.minute === "step") {
632
+ return " from " + getTime({ hour: h, minute: 0 }, opts) + " until " + getTime({ hour: (h + 1) % 24, minute: 0 }, opts);
633
+ }
634
+ if (ir.pattern.minute !== "*" && !isCadenceField(ir.pattern.minute)) {
635
+ return " at " + getTime({ hour: h, minute: 0 }, opts);
636
+ }
637
+ return " of the " + getTime({ hour: h, minute: 0 }, opts) + " hour";
638
+ }
639
+ if (ir.shapes.hour === "range") {
640
+ const bounds = hour.split("-");
641
+ return " " + rangeWindow({
642
+ continuous: ir.pattern.minute === "*",
643
+ from: +bounds[0],
644
+ throughMinute: 0,
645
+ to: +bounds[1]
646
+ }, opts);
647
+ }
648
+ return " during the " + hourSegmentTimes(ir, { minute: 0, second: null }, false, opts) + " hours";
649
+ }
650
+ function isContiguousHourRange(ir) {
651
+ return ir.shapes.hour === "range";
652
+ }
653
+ function confinableHour(ir) {
654
+ if (ir.shapes.hour !== "step") {
655
+ return true;
656
+ }
657
+ const segment = stepSegment(ir, "hour");
658
+ return ir.pattern.hour === "*/2" || segment.startToken.indexOf("-") !== -1;
659
+ }
660
+ function isMinuteStride(ir) {
661
+ if (ir.shapes.minute !== "list") {
662
+ return false;
663
+ }
664
+ const values = singleValues(segmentsOf(ir, "minute"));
665
+ return values !== null && arithmeticStep(values) !== null;
666
+ }
667
+ function confinementEligible(ir, lead) {
668
+ const { minute, hour } = ir.pattern;
669
+ const minuteStep = isCadenceField(minute) && minute !== "*";
670
+ if (!confinableHour(ir)) {
671
+ return false;
672
+ }
673
+ if (lead.secondLead) {
674
+ if (minuteStep) {
675
+ return minute === "*/2" && !isContiguousHourRange(ir);
676
+ }
677
+ if (isMinuteStride(ir) || ir.shapes.minute === "list" && ir.shapes.hour === "list") {
678
+ return false;
679
+ }
680
+ return true;
681
+ }
682
+ if (hour === "*/2") {
683
+ return true;
684
+ }
685
+ return ir.shapes.hour === "single" && minute === "*/2";
686
+ }
687
+ function confinement(ir, opts) {
688
+ if (!opts.style.untilWindow || opts.short) {
689
+ return null;
690
+ }
691
+ if (ir.pattern.minute === "*" && ir.pattern.hour === "*") {
692
+ return null;
693
+ }
694
+ const lead = leadingCadence(ir, opts);
695
+ if (!lead || !confinementEligible(ir, lead)) {
696
+ return null;
697
+ }
698
+ const minutePart = lead.secondLead ? minuteConfinement(ir, opts) : "";
699
+ return lead.text + minutePart + hourConfinement(ir, opts) + trailingQualifier(ir, opts);
491
700
  }
492
701
  var renderers = {
493
702
  clockTimes: renderClockTimes,
@@ -519,19 +728,9 @@ function renderStride(stride, opts) {
519
728
  if (start < interval && tiles) {
520
729
  return cadence + " from " + getNumber(start, opts) + " " + pluralize(start, unit) + " past the " + anchor;
521
730
  }
522
- const num = seriesNumber([start, last], opts);
731
+ const num = seriesNumber();
523
732
  return cadence + " from " + num(start) + through(opts) + num(last) + " " + pluralize(last, unit) + " past the " + anchor;
524
733
  }
525
- function singleValues(segments) {
526
- const values = [];
527
- for (const segment of segments) {
528
- if (segment.kind !== "single") {
529
- return null;
530
- }
531
- values.push(+segment.value);
532
- }
533
- return values;
534
- }
535
734
  function strideFromSegments(segments, unit, anchor, opts) {
536
735
  const values = singleValues(segments);
537
736
  const step = values && arithmeticStep(values);
@@ -580,9 +779,6 @@ function hourStrideCadence(stride, opts) {
580
779
  }
581
780
  return cadence + " from " + getTime({ hour: start, minute: 0 }, opts) + through(opts) + getTime({ hour: last, minute: 0 }, opts);
582
781
  }
583
- function offsetCleanStride(stride) {
584
- return stride.start < stride.interval && 24 % stride.interval === 0;
585
- }
586
782
  function unevenHourCadence(ir, opts) {
587
783
  const stride = hourStride(ir);
588
784
  if (!stride || offsetCleanStride(stride)) {
@@ -590,26 +786,8 @@ function unevenHourCadence(ir, opts) {
590
786
  }
591
787
  return hourStrideCadence(stride, opts);
592
788
  }
593
- function hourListStride(values) {
594
- if (values.length < 2) {
595
- return null;
596
- }
597
- const interval = values[1] - values[0];
598
- if (interval < 2) {
599
- return null;
600
- }
601
- for (let i = 2; i < values.length; i += 1) {
602
- if (values[i] - values[i - 1] !== interval) {
603
- return null;
604
- }
605
- }
606
- if (values[0] !== 0 && values.length < 5) {
607
- return null;
608
- }
609
- return { interval, last: values[values.length - 1], start: values[0] };
610
- }
611
789
  function hourStride(ir) {
612
- const segments = ir.analyses.segments.hour;
790
+ const segments = segmentsOf(ir, "hour");
613
791
  if (segments.length === 1 && segments[0].kind === "step") {
614
792
  const segment = segments[0];
615
793
  if (segment.fires.length < 2) {
@@ -646,9 +824,9 @@ function hourCadence(ir, minute, opts) {
646
824
  if (ir.pattern.second === "0" && fires <= maxClockTimes && offsetCleanStride(stride)) {
647
825
  return null;
648
826
  }
649
- const confinement = minute === 0 && subMinuteSecond(ir) && cleanStrideSegment(ir);
650
- if (confinement) {
651
- return secondsClause(ir, "minute", opts) + " for one minute " + everyNthHour(confinement, opts) + trailingQualifier(ir, opts);
827
+ const minuteZeroStride = minute === 0 && subMinuteSecond(ir) && cleanStrideSegment(ir);
828
+ if (minuteZeroStride) {
829
+ return secondsClause(ir, "minute", opts) + " for one minute " + everyNthHour(minuteZeroStride, opts) + trailingQualifier(ir, opts);
652
830
  }
653
831
  if (minute === 0 && ir.pattern.second === "0") {
654
832
  return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
@@ -656,7 +834,7 @@ function hourCadence(ir, minute, opts) {
656
834
  return hourCadenceLead(ir, minute, opts) + ", " + hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
657
835
  }
658
836
  function cleanStrideSegment(ir) {
659
- const segments = ir.analyses.segments.hour;
837
+ const segments = segmentsOf(ir, "hour");
660
838
  const segment = segments.length === 1 && segments[0];
661
839
  if (!segment || segment.kind !== "step" || segment.startToken.indexOf("-") !== -1 || !(segment.interval in stepOrdinals)) {
662
840
  return null;
@@ -664,32 +842,28 @@ function cleanStrideSegment(ir) {
664
842
  return segment;
665
843
  }
666
844
  function hasHourWindow(ir) {
667
- return ir.analyses.segments.hour.some(function range(segment) {
845
+ return segmentsOf(ir, "hour").some(function range(segment) {
668
846
  return segment.kind === "range";
669
847
  });
670
848
  }
671
849
  function hourRangeWindowTail(ir, opts) {
672
850
  const windows = [];
673
- const singles = [];
674
- ir.analyses.segments.hour.forEach(function classify(segment) {
851
+ const outlierHours = collectHourOutliers(ir);
852
+ segmentsOf(ir, "hour").forEach(function classify(segment) {
675
853
  if (segment.kind === "range") {
676
- windows.push("from " + getTime(
677
- { hour: +segment.bounds[0], minute: 0 },
678
- opts
679
- ) + through(opts) + getTime({ hour: +segment.bounds[1], minute: 0 }, opts));
680
- } else if (segment.kind === "step") {
681
- singles.push(...segment.fires);
682
- } else {
683
- singles.push(+segment.value);
854
+ windows.push(rangeWindow({
855
+ continuous: false,
856
+ from: +segment.bounds[0],
857
+ throughMinute: 0,
858
+ to: +segment.bounds[1]
859
+ }, opts));
684
860
  }
685
861
  });
686
- let phrase = "every hour " + joinList(windows, opts);
687
- if (singles.length) {
688
- phrase += " and at " + joinList(singles.map(function time(hour) {
689
- return getTime({ hour, minute: 0 }, opts);
690
- }), opts);
691
- }
692
- return phrase;
862
+ const phrase = "every hour " + joinList(windows, opts);
863
+ const times = outlierHours.map(function time(hour) {
864
+ return getTime({ hour, minute: 0 }, opts);
865
+ });
866
+ return phrase + outlierTail(times, opts);
693
867
  }
694
868
  function hourRangeCadence(ir, minute, opts) {
695
869
  if (minute !== 0 || !hasHourWindow(ir)) {
@@ -703,25 +877,29 @@ function hourRangeCadence(ir, minute, opts) {
703
877
  }
704
878
  return hourCadenceLead(ir, minute, opts) + ", " + hourRangeWindowTail(ir, opts) + trailingQualifier(ir, opts);
705
879
  }
706
- function seriesNumber(values, opts) {
707
- const anyBig = values.some(function big(v) {
708
- return +v > 10;
709
- });
880
+ function seriesNumber() {
710
881
  return function format(n) {
711
- return anyBig ? "" + n : getNumber(n, opts);
882
+ return "" + n;
883
+ };
884
+ }
885
+ function listNumber(count, opts) {
886
+ return count > 1 ? function asNumeral(n) {
887
+ return "" + n;
888
+ } : function spelled(n) {
889
+ return getNumber(n, opts);
712
890
  };
713
891
  }
714
892
  function numberWords(fires, opts) {
715
- return fires.map(seriesNumber(fires, opts));
893
+ return fires.map(listNumber(fires.length, opts));
716
894
  }
717
895
  function segmentWords(segments, opts) {
718
- const values = segments.flatMap(function collect(segment) {
896
+ const count = segments.reduce(function tally(sum, segment) {
719
897
  if (segment.kind === "range") {
720
- return segment.bounds;
898
+ return sum + 1;
721
899
  }
722
- return segment.kind === "step" ? segment.fires : [segment.value];
723
- });
724
- const num = seriesNumber(values, opts);
900
+ return sum + (segment.kind === "step" ? segment.fires.length : 1);
901
+ }, 0);
902
+ const num = listNumber(count, opts);
725
903
  return segments.flatMap(function word(segment) {
726
904
  if (segment.kind === "range") {
727
905
  return [num(segment.bounds[0]) + through(opts) + num(segment.bounds[1])];
@@ -753,6 +931,9 @@ function hourTimes(hours, opts) {
753
931
  });
754
932
  return joinList(times, opts);
755
933
  }
934
+ function singleHourFire(times) {
935
+ return times.kind === "fires" && times.fires.length === 1;
936
+ }
756
937
  function hourTimesFromPlan(ir, times, atContext, opts) {
757
938
  if (times.kind === "fires") {
758
939
  return hourTimes(times.fires, opts);
@@ -767,7 +948,7 @@ function segmentHours(segment) {
767
948
  }
768
949
  function hourSegmentTimes(ir, fold, atContext, opts) {
769
950
  const { minute, second } = fold;
770
- const segments = ir.analyses.segments.hour;
951
+ const segments = segmentsOf(ir, "hour");
771
952
  const plain = mixedTwelve(segments.flatMap(function entries(segment) {
772
953
  return segmentHours(segment).map(function entry(hour) {
773
954
  return { hour: +hour, minute, second };
@@ -800,28 +981,47 @@ function disambiguateTimes(pieces, segments, atContext) {
800
981
  return index === 0 ? piece : "at " + piece;
801
982
  });
802
983
  }
803
- function joinList(items, opts) {
984
+ function joinWith(items, conjunction, opts) {
804
985
  if (items.length <= 1) {
805
986
  return items.join("");
806
987
  }
807
988
  if (items.length === 2) {
808
- return items[0] + " and " + items[1];
989
+ return items[0] + conjunction + items[1];
809
990
  }
810
- const and = opts.style.serialComma ? ", and " : " and ";
811
- return items.slice(0, -1).join(", ") + and + items[items.length - 1];
991
+ const tail = opts.style.serialComma ? "," + conjunction : conjunction;
992
+ return items.slice(0, -1).join(", ") + tail + items[items.length - 1];
993
+ }
994
+ function joinList(items, opts) {
995
+ return joinWith(items, " and ", opts);
996
+ }
997
+ function joinOr(items, opts) {
998
+ return joinWith(items, " or ", opts);
812
999
  }
813
- var trailingWords = { all: "", month: "in ", stepDate: "on ", weekday: "on " };
1000
+ var trailingWords = {
1001
+ all: "",
1002
+ month: "in ",
1003
+ recurringWeekday: true,
1004
+ stepDate: "on ",
1005
+ weekday: "on "
1006
+ };
814
1007
  var leadingWords = {
815
1008
  all: "every day",
816
1009
  month: "every day in ",
1010
+ recurringWeekday: false,
817
1011
  stepDate: "",
818
1012
  weekday: "every "
819
1013
  };
820
1014
  function trailingQualifier(ir, opts) {
1015
+ if (isDayUnion(ir, opts)) {
1016
+ return dayUnionCondition(ir, opts);
1017
+ }
821
1018
  const phrase = dayQualifier(ir, trailingWords, opts);
822
1019
  return phrase && " " + phrase;
823
1020
  }
824
1021
  function interpretDayQualifier(ir, opts) {
1022
+ if (isDayUnion(ir, opts)) {
1023
+ return "";
1024
+ }
825
1025
  return dayQualifier(ir, leadingWords, opts) + " ";
826
1026
  }
827
1027
  function dayQualifier(ir, words, opts) {
@@ -833,7 +1033,11 @@ function dayQualifier(ir, words, opts) {
833
1033
  return datePhrase(ir, words, opts);
834
1034
  }
835
1035
  if (pattern.weekday !== "*") {
836
- const weekdays = quartzWeekdayPhrase(pattern.weekday, opts) || words.weekday + weekdayPhrase(ir, opts);
1036
+ const quartzWeekday = quartzWeekdayPhrase(pattern.weekday, opts);
1037
+ if (quartzWeekday) {
1038
+ return monthScopeForRecurrence(quartzWeekday, ir, opts);
1039
+ }
1040
+ const weekdays = words.weekday + weekdayPhrase(ir, words.recurringWeekday, opts);
837
1041
  return weekdays + monthScope(ir, opts);
838
1042
  }
839
1043
  if (pattern.month !== "*") {
@@ -845,10 +1049,14 @@ function datePhrase(ir, words, opts) {
845
1049
  const pattern = ir.pattern;
846
1050
  const quartzDate = quartzDatePhrase(pattern.date, opts);
847
1051
  if (quartzDate) {
848
- return quartzDate + monthScope(ir, opts);
1052
+ return monthScopeForRecurrence(quartzDate, ir, opts);
849
1053
  }
850
1054
  if (isOpenStep(pattern.date)) {
851
- return words.stepDate + stepDates(pattern.date) + monthScope(ir, opts);
1055
+ return monthScopeForRecurrence(
1056
+ words.stepDate + stepDates(pattern.date),
1057
+ ir,
1058
+ opts
1059
+ );
852
1060
  }
853
1061
  if (pattern.month !== "*" && !monthFoldsIntoDate(ir)) {
854
1062
  return "on the " + dateOrdinals(ir, opts) + monthScope(ir, opts);
@@ -860,13 +1068,88 @@ function datePhrase(ir, words, opts) {
860
1068
  }
861
1069
  function monthFoldsIntoDate(ir) {
862
1070
  return !oddEvenMonth(ir.pattern.month) && // Reached only with a restricted month, which has segments.
863
- ir.analyses.segments.month.every(function flat(segment) {
1071
+ segmentsOf(ir, "month").every(function flat(segment) {
864
1072
  return segment.kind !== "range";
865
1073
  });
866
1074
  }
1075
+ function isDayUnion(ir, opts) {
1076
+ return ir.pattern.date !== "*" && ir.pattern.weekday !== "*" && !!opts.style.untilWindow && !opts.short;
1077
+ }
1078
+ function dayUnionCondition(ir, opts) {
1079
+ const pieces = [
1080
+ ...dayUnionDatePieces(ir, opts),
1081
+ ...dayUnionWeekdayPieces(ir, opts)
1082
+ ];
1083
+ return " whenever the day is " + joinOr(pieces, opts);
1084
+ }
1085
+ function dayUnionMonthLead(ir, opts) {
1086
+ if (ir.pattern.month === "*") {
1087
+ return "";
1088
+ }
1089
+ return "in " + monthName(ir, opts) + " ";
1090
+ }
1091
+ function dayUnionDatePieces(ir, opts) {
1092
+ const dateField = ir.pattern.date;
1093
+ const quartz = quartzDatePhrase(dateField, opts);
1094
+ if (quartz) {
1095
+ return [quartz.replace(/^on /, "")];
1096
+ }
1097
+ const oddEven = oddEvenDay(dateField);
1098
+ if (oddEven) {
1099
+ return [oddEven];
1100
+ }
1101
+ const pieces = [];
1102
+ segmentsOf(ir, "date").forEach(function expand(segment) {
1103
+ if (segment.kind === "range") {
1104
+ pieces.push("from the " + getOrdinal(segment.bounds[0]) + through(opts) + "the " + getOrdinal(segment.bounds[1]));
1105
+ } else if (segment.kind === "step") {
1106
+ segment.fires.forEach(function fire(value) {
1107
+ pieces.push("the " + getOrdinal(value));
1108
+ });
1109
+ } else {
1110
+ pieces.push("the " + getOrdinal(segment.value));
1111
+ }
1112
+ });
1113
+ return pieces;
1114
+ }
1115
+ function dayUnionWeekdayPieces(ir, opts) {
1116
+ const weekdayField = ir.pattern.weekday;
1117
+ const quartz = quartzWeekdayPhrase(weekdayField, opts);
1118
+ if (quartz) {
1119
+ return [quartz.replace(/^on /, "")];
1120
+ }
1121
+ const pieces = [];
1122
+ segmentsOf(ir, "weekday").forEach(function expand(segment) {
1123
+ if (segment.kind === "range" && segment.bounds[0] === "1" && segment.bounds[1] === "5") {
1124
+ pieces.push("a weekday");
1125
+ } else if (segment.kind === "range") {
1126
+ pieces.push("a " + getWeekday(segment.bounds[0], opts) + through(opts) + "a " + getWeekday(segment.bounds[1], opts));
1127
+ } else if (segment.kind === "step") {
1128
+ segment.fires.forEach(function fire(value) {
1129
+ pieces.push("a " + getWeekday(value, opts));
1130
+ });
1131
+ } else {
1132
+ pieces.push("a " + getWeekday(segment.value, opts));
1133
+ }
1134
+ });
1135
+ return pieces;
1136
+ }
1137
+ function oddEvenDay(dateField) {
1138
+ if (!isOpenStep(dateField)) {
1139
+ return null;
1140
+ }
1141
+ const [start, step] = dateField.split("/");
1142
+ if (+step !== 2) {
1143
+ return null;
1144
+ }
1145
+ if (start === "*" || start === "1") {
1146
+ return "an odd-numbered day";
1147
+ }
1148
+ return start === "2" ? "an even-numbered day" : null;
1149
+ }
867
1150
  function dateOrWeekday(ir, opts) {
868
1151
  const pattern = ir.pattern;
869
- const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) || "on " + weekdayPhrase(ir, opts);
1152
+ const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) || "on " + weekdayPhrase(ir, false, opts);
870
1153
  if (pattern.month !== "*" && monthFoldsIntoDate(ir) && !quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
871
1154
  return "on " + monthDatePhrase(ir, opts) + " or " + weekdayPart + " in " + monthName(ir, opts);
872
1155
  }
@@ -917,10 +1200,13 @@ function quartzWeekdayPhrase(weekdayField, opts) {
917
1200
  function monthDatePhrase(ir, opts) {
918
1201
  const month = monthName(ir, opts);
919
1202
  const days = renderSegments(
920
- ir.analyses.segments.date,
1203
+ segmentsOf(ir, "date"),
921
1204
  opts.style.ordinals ? getOrdinal : cardinalDay,
922
1205
  opts
923
1206
  );
1207
+ if (opts.style.dayFirst && ir.shapes.date === "single" && ir.shapes.month !== "single") {
1208
+ return "the " + getOrdinal(ir.pattern.date) + " of " + month;
1209
+ }
924
1210
  return opts.style.dayFirst ? days + " " + month : month + " " + days;
925
1211
  }
926
1212
  function cardinalDay(value) {
@@ -932,6 +1218,19 @@ function monthScope(ir, opts) {
932
1218
  }
933
1219
  return " in " + monthName(ir, opts);
934
1220
  }
1221
+ function monthScopeForRecurrence(phrase, ir, opts) {
1222
+ if (ir.pattern.month === "*") {
1223
+ return phrase;
1224
+ }
1225
+ const carriesRecurrence = phrase.indexOf(" of the month") !== -1;
1226
+ if (carriesRecurrence && ir.shapes.month === "range") {
1227
+ return phrase.replace(" of the month", " of each month") + " from " + monthName(ir, opts);
1228
+ }
1229
+ if (carriesRecurrence && (ir.shapes.month === "single" || ir.shapes.month === "step")) {
1230
+ return phrase.replace(" of the month", "") + " in " + monthName(ir, opts);
1231
+ }
1232
+ return phrase + " in " + monthName(ir, opts);
1233
+ }
935
1234
  function stepDates(dateField) {
936
1235
  const parts = dateField.split("/");
937
1236
  const interval = +parts[1];
@@ -944,14 +1243,14 @@ function stepDates(dateField) {
944
1243
  return phrase;
945
1244
  }
946
1245
  function dateOrdinals(ir, opts) {
947
- return renderSegments(ir.analyses.segments.date, getOrdinal, opts);
1246
+ return renderSegments(segmentsOf(ir, "date"), getOrdinal, opts);
948
1247
  }
949
1248
  function monthName(ir, opts) {
950
1249
  const oddEven = oddEvenMonth(ir.pattern.month);
951
1250
  if (oddEven) {
952
1251
  return oddEven;
953
1252
  }
954
- return renderSegments(ir.analyses.segments.month, function name(value) {
1253
+ return renderSegments(segmentsOf(ir, "month"), function name(value) {
955
1254
  return getMonth(value, opts);
956
1255
  }, opts);
957
1256
  }
@@ -968,11 +1267,21 @@ function oddEvenMonth(monthField) {
968
1267
  }
969
1268
  return start === "2" ? "every even-numbered month" : null;
970
1269
  }
971
- function weekdayPhrase(ir, opts) {
972
- const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday);
973
- return renderSegments(segments, function name(value) {
1270
+ function weekdayPhrase(ir, recurring, opts) {
1271
+ const segments = orderWeekdaysForDisplay(segmentsOf(ir, "weekday"));
1272
+ const hasRange = segments.some(function range(segment) {
1273
+ return segment.kind === "range";
1274
+ });
1275
+ const name = recurring && !hasRange ? function plural(value) {
1276
+ return pluralWeekday(value, opts);
1277
+ } : function singular(value) {
974
1278
  return getWeekday(value, opts);
975
- }, opts);
1279
+ };
1280
+ return renderSegments(segments, name, opts);
1281
+ }
1282
+ function pluralWeekday(value, opts) {
1283
+ const name = getWeekday(value, opts);
1284
+ return opts.short ? name : name + "s";
976
1285
  }
977
1286
  function renderSegments(segments, word, opts) {
978
1287
  const pieces = [];
@@ -987,16 +1296,13 @@ function renderSegments(segments, word, opts) {
987
1296
  });
988
1297
  return joinList(pieces, opts);
989
1298
  }
990
- function isOpenStep(field) {
991
- return field.indexOf("/") !== -1 && field.indexOf("-") === -1 && field.indexOf(",") === -1;
992
- }
993
1299
  function applyYear(description, ir, opts) {
994
1300
  const yearField = ir.pattern.year;
995
1301
  if (yearField === "*") {
996
1302
  return description;
997
1303
  }
998
1304
  if (yearField.indexOf("/") !== -1) {
999
- return description + " " + stepYears(yearField, opts);
1305
+ return description + ", " + stepYears(yearField, opts);
1000
1306
  }
1001
1307
  const label = yearLabel(yearField, opts);
1002
1308
  if (yearField.indexOf("-") === -1 && yearField.indexOf(",") === -1 && ir.pattern.date !== "*" && description.indexOf(" at ") !== -1) {
@@ -1009,6 +1315,9 @@ function yearLabel(yearField, opts) {
1009
1315
  if (yearField.indexOf(",") !== -1) {
1010
1316
  return joinList(yearField.split(","), opts);
1011
1317
  }
1318
+ if (yearField.indexOf("-") !== -1) {
1319
+ return yearField.split("-").join(through(opts));
1320
+ }
1012
1321
  return yearField;
1013
1322
  }
1014
1323
  function stepYears(yearField, opts) {
@@ -1018,7 +1327,7 @@ function stepYears(yearField, opts) {
1018
1327
  if (interval <= 1) {
1019
1328
  return "every year";
1020
1329
  }
1021
- let phrase = "every " + getNumber(interval, opts) + " years";
1330
+ let phrase = interval === 2 ? "every other year" : "every " + getNumber(interval, opts) + " years";
1022
1331
  if (start !== "*" && start !== "0") {
1023
1332
  phrase += " from " + start;
1024
1333
  }