cronli5 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cronli5.cjs CHANGED
@@ -110,6 +110,26 @@ function arithmeticStep(values) {
110
110
  }
111
111
  return { start: values[0], interval, last: values[values.length - 1] };
112
112
  }
113
+ function weekdayDisplayKey(value) {
114
+ return value === 0 ? 7 : value;
115
+ }
116
+ function orderWeekdaysForDisplay(segments) {
117
+ const flattened = segments.flatMap(function flat(segment) {
118
+ return segment.kind === "step" ? segment.fires.map(function single(value) {
119
+ return { kind: "single", value: "" + value };
120
+ }) : [segment];
121
+ });
122
+ function key(segment) {
123
+ return segment.kind === "range" ? weekdayDisplayKey(+segment.bounds[0]) : weekdayDisplayKey(+segment.value);
124
+ }
125
+ return flattened.map(function index(segment, position) {
126
+ return [segment, position];
127
+ }).sort(function byDisplayKey(a, b) {
128
+ return key(a[0]) - key(b[0]) || a[1] - b[1];
129
+ }).map(function unwrap(pair) {
130
+ return pair[0];
131
+ });
132
+ }
113
133
  function toFieldNumber(token, numberMap) {
114
134
  return isNonNegativeInteger(token) ? +token : numberMap[token.toUpperCase()];
115
135
  }
@@ -855,7 +875,8 @@ var dialects = {
855
875
  pm: "p.m.",
856
876
  sep: ":",
857
877
  serialComma: true,
858
- through: " through "
878
+ through: " through ",
879
+ untilWindow: true
859
880
  },
860
881
  house: {
861
882
  am: "AM",
@@ -872,7 +893,7 @@ var dialects = {
872
893
  };
873
894
  function resolveDialect(dialect) {
874
895
  if (typeof dialect === "object" && dialect !== null) {
875
- return { ...dialects.us, ...dialect };
896
+ return { ...dialects.us, untilWindow: false, ...dialect };
876
897
  }
877
898
  const name = dialect === "uk" ? "gb" : dialect;
878
899
  return dialects[name] || dialects.us;
@@ -944,7 +965,9 @@ function normalizeOptions(options) {
944
965
  };
945
966
  }
946
967
  function describe(ir, opts) {
947
- return applyYear(render(ir, ir.plan, opts), ir, opts);
968
+ const body = confinement(ir, opts) ?? render(ir, ir.plan, opts);
969
+ const lead = isDayUnion(ir, opts) ? dayUnionMonthLead(ir, opts) : "";
970
+ return applyYear(lead + body, ir, opts);
948
971
  }
949
972
  function render(ir, plan, opts) {
950
973
  const renderer = renderers[plan.kind];
@@ -1037,7 +1060,7 @@ function secondsClause(ir, anchor, opts) {
1037
1060
  }
1038
1061
  if (shape === "range") {
1039
1062
  const bounds = secondField.split("-");
1040
- const num = seriesNumber(bounds, opts);
1063
+ const num = seriesNumber();
1041
1064
  return "every second from " + num(bounds[0]) + through(opts) + num(bounds[1]) + " past the " + anchor;
1042
1065
  }
1043
1066
  if (shape === "single") {
@@ -1103,15 +1126,21 @@ function renderMinutesAcrossHours(ir, plan, opts) {
1103
1126
  }
1104
1127
  return "every minute during the " + hourTimesFromPlan(ir, plan.times, false, opts) + " hours" + trailingQualifier(ir, opts);
1105
1128
  }
1106
- const lead = plan.form === "range" ? minuteRangeLead(ir.pattern.minute, opts) : (
1107
- // The 'list' form is a minute list, which has segments; an offset/uneven
1108
- // step enumerated to that list reads as a stride.
1109
- strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts) ?? listPastThe(
1110
- segmentWords(ir.analyses.segments.minute, opts),
1111
- "minute",
1112
- "hour",
1113
- opts
1114
- )
1129
+ if (plan.form === "range") {
1130
+ const lead2 = minuteRangeLead(ir.pattern.minute, opts);
1131
+ if (cadence !== null) {
1132
+ return lead2 + ", " + cadence + trailingQualifier(ir, opts);
1133
+ }
1134
+ if (singleHourFire(plan.times)) {
1135
+ return lead2 + ", at " + hourTimesFromPlan(ir, plan.times, true, opts) + trailingQualifier(ir, opts);
1136
+ }
1137
+ return lead2 + " during the " + hourTimesFromPlan(ir, plan.times, false, opts) + " hours" + trailingQualifier(ir, opts);
1138
+ }
1139
+ const lead = strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts) ?? listPastThe(
1140
+ segmentWords(ir.analyses.segments.minute, opts),
1141
+ "minute",
1142
+ "hour",
1143
+ opts
1115
1144
  );
1116
1145
  if (cadence !== null) {
1117
1146
  return lead + ", " + cadence + trailingQualifier(ir, opts);
@@ -1148,7 +1177,7 @@ function renderMinuteSpanAcrossHourStep(ir, plan, opts) {
1148
1177
  }
1149
1178
  function minuteRangeLead(minuteField, opts) {
1150
1179
  const bounds = minuteField.split("-");
1151
- const num = seriesNumber(bounds, opts);
1180
+ const num = seriesNumber();
1152
1181
  return "every minute from " + num(bounds[0]) + through(opts) + num(bounds[1]) + " past the hour";
1153
1182
  }
1154
1183
  function renderEveryHour(ir, plan, opts) {
@@ -1188,10 +1217,18 @@ function renderHourStep(ir, plan, opts) {
1188
1217
  return stepHours(ir.analyses.segments.hour[0], opts) + trailingQualifier(ir, opts);
1189
1218
  }
1190
1219
  function boundedWindow(plan) {
1191
- return { from: plan.from, last: plan.boundMinute ?? 0, to: plan.to };
1220
+ const last = plan.minuteForm === "wildcard" ? plan.boundMinute ?? 0 : 0;
1221
+ return { from: plan.from, last, to: plan.to };
1222
+ }
1223
+ function rangeWindow(from, to, throughMinute, opts) {
1224
+ const open = "from " + getTime({ hour: from, minute: 0 }, opts);
1225
+ if (opts.style.untilWindow && !opts.short && from !== to) {
1226
+ return open + " until " + getTime({ hour: (to + 1) % 24, minute: 0 }, opts);
1227
+ }
1228
+ return open + through(opts) + getTime({ hour: to, minute: throughMinute }, opts);
1192
1229
  }
1193
1230
  function hourWindow(window, opts) {
1194
- return "from " + getTime({ hour: window.from, minute: 0 }, opts) + through(opts) + getTime({ hour: window.to, minute: window.last }, opts);
1231
+ return rangeWindow(window.from, window.to, window.last, opts);
1195
1232
  }
1196
1233
  function renderClockTimes(ir, plan, opts) {
1197
1234
  if (ir.shapes.minute === "single") {
@@ -1210,7 +1247,10 @@ function renderClockTimes(ir, plan, opts) {
1210
1247
  plain
1211
1248
  }, opts);
1212
1249
  });
1213
- return interpretDayQualifier(ir, opts) + "at " + joinList(times, opts);
1250
+ return interpretDayQualifier(ir, opts) + "at " + joinList(times, opts) + dayUnionTrail(ir, opts);
1251
+ }
1252
+ function dayUnionTrail(ir, opts) {
1253
+ return isDayUnion(ir, opts) ? dayUnionCondition(ir, opts) : "";
1214
1254
  }
1215
1255
  function renderCompactClockTimes(ir, plan, opts) {
1216
1256
  if (plan.fold) {
@@ -1225,7 +1265,7 @@ function renderCompactClockTimes(ir, plan, opts) {
1225
1265
  return foldedHourWindows(ir, plan, opts) + trailingQualifier(ir, opts);
1226
1266
  }
1227
1267
  const fold = { minute: plan.minute, second: ir.analyses.clockSecond };
1228
- return interpretDayQualifier(ir, opts) + "at " + hourSegmentTimes(ir, fold, true, opts);
1268
+ return interpretDayQualifier(ir, opts) + "at " + hourSegmentTimes(ir, fold, true, opts) + dayUnionTrail(ir, opts);
1229
1269
  }
1230
1270
  const minuteLead = (
1231
1271
  // The non-fold branch is a minute list, which has segments. An
@@ -1244,26 +1284,161 @@ function renderCompactClockTimes(ir, plan, opts) {
1244
1284
  function foldedHourWindows(ir, plan, opts) {
1245
1285
  const minute = plan.minute;
1246
1286
  const windows = [];
1247
- const singles = [];
1287
+ const outliers = collectHourOutliers(ir);
1288
+ const times = outliers.hours.map(function time(hour) {
1289
+ return getTime({ hour, minute }, opts);
1290
+ });
1248
1291
  ir.analyses.segments.hour.forEach(function classify(segment) {
1249
1292
  if (segment.kind === "range") {
1250
- windows.push("from " + getTime(
1251
- { hour: segment.bounds[0], minute: 0 },
1293
+ windows.push(rangeWindow(
1294
+ +segment.bounds[0],
1295
+ +segment.bounds[1],
1296
+ minute,
1252
1297
  opts
1253
- ) + through(opts) + getTime({ hour: segment.bounds[1], minute }, opts));
1254
- } else if (segment.kind === "step") {
1255
- singles.push(...segment.fires);
1256
- } else {
1257
- singles.push(+segment.value);
1298
+ ));
1299
+ }
1300
+ });
1301
+ const phrase = rangeMinuteLead(ir, opts) + " " + joinList(windows, opts);
1302
+ return phrase + outlierTail(times, outliers.pureStrays, opts);
1303
+ }
1304
+ function collectHourOutliers(ir) {
1305
+ const hours = [];
1306
+ let pureStrays = true;
1307
+ ir.analyses.segments.hour.forEach(function classify(segment) {
1308
+ if (segment.kind === "step") {
1309
+ hours.push(...segment.fires);
1310
+ pureStrays = false;
1311
+ } else if (segment.kind !== "range") {
1312
+ hours.push(+segment.value);
1258
1313
  }
1259
1314
  });
1260
- let phrase = rangeMinuteLead(ir, opts) + " " + joinList(windows, opts);
1261
- if (singles.length) {
1262
- phrase += " and at " + joinList(singles.map(function time(hour) {
1263
- return getTime({ hour, minute }, opts);
1264
- }), opts);
1315
+ return { hours, pureStrays };
1316
+ }
1317
+ function outlierTail(times, pureStrays, opts) {
1318
+ if (!times.length) {
1319
+ return "";
1265
1320
  }
1266
- return phrase;
1321
+ const connector = pureStrays && opts.style.untilWindow && !opts.short ? " plus " : " and at ";
1322
+ return connector + joinList(times, opts);
1323
+ }
1324
+ function isCadenceField(token) {
1325
+ return token === "*" || token.startsWith("*/") && token.indexOf("-") === -1;
1326
+ }
1327
+ function leadingCadence(ir, opts) {
1328
+ const { second, minute } = ir.pattern;
1329
+ if (isCadenceField(second)) {
1330
+ return { secondLead: true, text: secondsClause(ir, "minute", opts) };
1331
+ }
1332
+ if (second === "0" && isCadenceField(minute)) {
1333
+ const text = minute === "*" ? "every minute" : (
1334
+ // A clean minute step's first segment is a step segment.
1335
+ stepCycle60(
1336
+ ir.analyses.segments.minute[0],
1337
+ "minute",
1338
+ "hour",
1339
+ opts
1340
+ )
1341
+ );
1342
+ return { secondLead: false, text };
1343
+ }
1344
+ return null;
1345
+ }
1346
+ function minuteConfinement(ir, opts) {
1347
+ const minute = ir.pattern.minute;
1348
+ if (minute === "*") {
1349
+ return "";
1350
+ }
1351
+ if (isCadenceField(minute)) {
1352
+ return " of every other minute";
1353
+ }
1354
+ const segments = ir.analyses.segments.minute;
1355
+ if (ir.shapes.minute === "single") {
1356
+ return " during minute :" + pad(minute);
1357
+ }
1358
+ if (ir.shapes.minute === "range") {
1359
+ const bounds = minute.split("-");
1360
+ return " during minutes :" + pad(bounds[0]) + through(opts) + ":" + pad(bounds[1]);
1361
+ }
1362
+ const values = segmentWords(segments, opts).map(function colon(word) {
1363
+ return ":" + pad(word);
1364
+ });
1365
+ return " during minutes " + joinList(values, opts);
1366
+ }
1367
+ function hourConfinement(ir, opts) {
1368
+ const hour = ir.pattern.hour;
1369
+ if (hour === "*") {
1370
+ const minutePinned = ir.pattern.minute !== "*" && !isCadenceField(ir.pattern.minute);
1371
+ return minutePinned ? " of every hour" : "";
1372
+ }
1373
+ if (isCadenceField(hour)) {
1374
+ return hour === "*/2" ? " of every other hour" : "";
1375
+ }
1376
+ if (ir.shapes.hour === "single") {
1377
+ const h = +hour;
1378
+ if (ir.shapes.minute === "step") {
1379
+ return " from " + getTime({ hour: h, minute: 0 }, opts) + " until " + getTime({ hour: (h + 1) % 24, minute: 0 }, opts);
1380
+ }
1381
+ if (ir.pattern.minute !== "*" && !isCadenceField(ir.pattern.minute)) {
1382
+ return " at " + getTime({ hour: h, minute: 0 }, opts);
1383
+ }
1384
+ return " of the " + getTime({ hour: h, minute: 0 }, opts) + " hour";
1385
+ }
1386
+ if (ir.shapes.hour === "range") {
1387
+ const bounds = hour.split("-");
1388
+ return " " + rangeWindow(+bounds[0], +bounds[1], 0, opts);
1389
+ }
1390
+ return " during the " + hourSegmentTimes(ir, { minute: 0, second: null }, false, opts) + " hours";
1391
+ }
1392
+ function isContiguousHourRange(ir) {
1393
+ return ir.shapes.hour === "range";
1394
+ }
1395
+ function confinableHour(ir) {
1396
+ if (ir.shapes.hour !== "step") {
1397
+ return true;
1398
+ }
1399
+ const segment = ir.analyses.segments.hour[0];
1400
+ return ir.pattern.hour === "*/2" || segment.startToken.indexOf("-") !== -1;
1401
+ }
1402
+ function isMinuteStride(ir) {
1403
+ if (ir.shapes.minute !== "list") {
1404
+ return false;
1405
+ }
1406
+ const values = singleValues(ir.analyses.segments.minute);
1407
+ return values !== null && arithmeticStep(values) !== null;
1408
+ }
1409
+ function confinementEligible(ir, lead) {
1410
+ const { minute, hour } = ir.pattern;
1411
+ const minuteStep = isCadenceField(minute) && minute !== "*";
1412
+ if (!confinableHour(ir)) {
1413
+ return false;
1414
+ }
1415
+ if (lead.secondLead) {
1416
+ if (minuteStep) {
1417
+ return minute === "*/2" && !isContiguousHourRange(ir);
1418
+ }
1419
+ if (isMinuteStride(ir) || ir.shapes.minute === "list" && ir.shapes.hour === "list") {
1420
+ return false;
1421
+ }
1422
+ return true;
1423
+ }
1424
+ if (hour === "*/2") {
1425
+ return true;
1426
+ }
1427
+ return ir.shapes.hour === "single" && minute === "*/2";
1428
+ }
1429
+ function confinement(ir, opts) {
1430
+ if (!opts.style.untilWindow || opts.short) {
1431
+ return null;
1432
+ }
1433
+ if (ir.pattern.minute === "*" && ir.pattern.hour === "*") {
1434
+ return null;
1435
+ }
1436
+ const lead = leadingCadence(ir, opts);
1437
+ if (!lead || !confinementEligible(ir, lead)) {
1438
+ return null;
1439
+ }
1440
+ const minutePart = lead.secondLead ? minuteConfinement(ir, opts) : "";
1441
+ return lead.text + minutePart + hourConfinement(ir, opts) + trailingQualifier(ir, opts);
1267
1442
  }
1268
1443
  var renderers = {
1269
1444
  clockTimes: renderClockTimes,
@@ -1295,7 +1470,7 @@ function renderStride(stride, opts) {
1295
1470
  if (start < interval && tiles) {
1296
1471
  return cadence + " from " + getNumber(start, opts) + " " + pluralize(start, unit) + " past the " + anchor;
1297
1472
  }
1298
- const num = seriesNumber([start, last], opts);
1473
+ const num = seriesNumber();
1299
1474
  return cadence + " from " + num(start) + through(opts) + num(last) + " " + pluralize(last, unit) + " past the " + anchor;
1300
1475
  }
1301
1476
  function singleValues(segments) {
@@ -1422,9 +1597,9 @@ function hourCadence(ir, minute, opts) {
1422
1597
  if (ir.pattern.second === "0" && fires <= maxClockTimes && offsetCleanStride(stride)) {
1423
1598
  return null;
1424
1599
  }
1425
- const confinement = minute === 0 && subMinuteSecond(ir) && cleanStrideSegment(ir);
1426
- if (confinement) {
1427
- return secondsClause(ir, "minute", opts) + " for one minute " + everyNthHour(confinement, opts) + trailingQualifier(ir, opts);
1600
+ const minuteZeroStride = minute === 0 && subMinuteSecond(ir) && cleanStrideSegment(ir);
1601
+ if (minuteZeroStride) {
1602
+ return secondsClause(ir, "minute", opts) + " for one minute " + everyNthHour(minuteZeroStride, opts) + trailingQualifier(ir, opts);
1428
1603
  }
1429
1604
  if (minute === 0 && ir.pattern.second === "0") {
1430
1605
  return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
@@ -1446,26 +1621,22 @@ function hasHourWindow(ir) {
1446
1621
  }
1447
1622
  function hourRangeWindowTail(ir, opts) {
1448
1623
  const windows = [];
1449
- const singles = [];
1624
+ const outliers = collectHourOutliers(ir);
1450
1625
  ir.analyses.segments.hour.forEach(function classify(segment) {
1451
1626
  if (segment.kind === "range") {
1452
- windows.push("from " + getTime(
1453
- { hour: +segment.bounds[0], minute: 0 },
1627
+ windows.push(rangeWindow(
1628
+ +segment.bounds[0],
1629
+ +segment.bounds[1],
1630
+ 0,
1454
1631
  opts
1455
- ) + through(opts) + getTime({ hour: +segment.bounds[1], minute: 0 }, opts));
1456
- } else if (segment.kind === "step") {
1457
- singles.push(...segment.fires);
1458
- } else {
1459
- singles.push(+segment.value);
1632
+ ));
1460
1633
  }
1461
1634
  });
1462
- let phrase = "every hour " + joinList(windows, opts);
1463
- if (singles.length) {
1464
- phrase += " and at " + joinList(singles.map(function time(hour) {
1465
- return getTime({ hour, minute: 0 }, opts);
1466
- }), opts);
1467
- }
1468
- return phrase;
1635
+ const phrase = "every hour " + joinList(windows, opts);
1636
+ const times = outliers.hours.map(function time(hour) {
1637
+ return getTime({ hour, minute: 0 }, opts);
1638
+ });
1639
+ return phrase + outlierTail(times, outliers.pureStrays, opts);
1469
1640
  }
1470
1641
  function hourRangeCadence(ir, minute, opts) {
1471
1642
  if (minute !== 0 || !hasHourWindow(ir)) {
@@ -1479,25 +1650,29 @@ function hourRangeCadence(ir, minute, opts) {
1479
1650
  }
1480
1651
  return hourCadenceLead(ir, minute, opts) + ", " + hourRangeWindowTail(ir, opts) + trailingQualifier(ir, opts);
1481
1652
  }
1482
- function seriesNumber(values, opts) {
1483
- const anyBig = values.some(function big(v) {
1484
- return +v > 10;
1485
- });
1653
+ function seriesNumber() {
1486
1654
  return function format(n) {
1487
- return anyBig ? "" + n : getNumber(n, opts);
1655
+ return "" + n;
1656
+ };
1657
+ }
1658
+ function listNumber(count, opts) {
1659
+ return count > 1 ? function asNumeral(n) {
1660
+ return "" + n;
1661
+ } : function spelled(n) {
1662
+ return getNumber(n, opts);
1488
1663
  };
1489
1664
  }
1490
1665
  function numberWords(fires, opts) {
1491
- return fires.map(seriesNumber(fires, opts));
1666
+ return fires.map(listNumber(fires.length, opts));
1492
1667
  }
1493
1668
  function segmentWords(segments, opts) {
1494
- const values = segments.flatMap(function collect(segment) {
1669
+ const count = segments.reduce(function tally(sum, segment) {
1495
1670
  if (segment.kind === "range") {
1496
- return segment.bounds;
1671
+ return sum + 1;
1497
1672
  }
1498
- return segment.kind === "step" ? segment.fires : [segment.value];
1499
- });
1500
- const num = seriesNumber(values, opts);
1673
+ return sum + (segment.kind === "step" ? segment.fires.length : 1);
1674
+ }, 0);
1675
+ const num = listNumber(count, opts);
1501
1676
  return segments.flatMap(function word(segment) {
1502
1677
  if (segment.kind === "range") {
1503
1678
  return [num(segment.bounds[0]) + through(opts) + num(segment.bounds[1])];
@@ -1529,6 +1704,9 @@ function hourTimes(hours, opts) {
1529
1704
  });
1530
1705
  return joinList(times, opts);
1531
1706
  }
1707
+ function singleHourFire(times) {
1708
+ return times.kind === "fires" && times.fires.length === 1;
1709
+ }
1532
1710
  function hourTimesFromPlan(ir, times, atContext, opts) {
1533
1711
  if (times.kind === "fires") {
1534
1712
  return hourTimes(times.fires, opts);
@@ -1576,28 +1754,47 @@ function disambiguateTimes(pieces, segments, atContext) {
1576
1754
  return index === 0 ? piece : "at " + piece;
1577
1755
  });
1578
1756
  }
1579
- function joinList(items, opts) {
1757
+ function joinWith(items, conjunction, opts) {
1580
1758
  if (items.length <= 1) {
1581
1759
  return items.join("");
1582
1760
  }
1583
1761
  if (items.length === 2) {
1584
- return items[0] + " and " + items[1];
1762
+ return items[0] + conjunction + items[1];
1585
1763
  }
1586
- const and = opts.style.serialComma ? ", and " : " and ";
1587
- return items.slice(0, -1).join(", ") + and + items[items.length - 1];
1764
+ const tail = opts.style.serialComma ? "," + conjunction : conjunction;
1765
+ return items.slice(0, -1).join(", ") + tail + items[items.length - 1];
1766
+ }
1767
+ function joinList(items, opts) {
1768
+ return joinWith(items, " and ", opts);
1769
+ }
1770
+ function joinOr(items, opts) {
1771
+ return joinWith(items, " or ", opts);
1588
1772
  }
1589
- var trailingWords = { all: "", month: "in ", stepDate: "on ", weekday: "on " };
1773
+ var trailingWords = {
1774
+ all: "",
1775
+ month: "in ",
1776
+ recurringWeekday: true,
1777
+ stepDate: "on ",
1778
+ weekday: "on "
1779
+ };
1590
1780
  var leadingWords = {
1591
1781
  all: "every day",
1592
1782
  month: "every day in ",
1783
+ recurringWeekday: false,
1593
1784
  stepDate: "",
1594
1785
  weekday: "every "
1595
1786
  };
1596
1787
  function trailingQualifier(ir, opts) {
1788
+ if (isDayUnion(ir, opts)) {
1789
+ return dayUnionCondition(ir, opts);
1790
+ }
1597
1791
  const phrase = dayQualifier(ir, trailingWords, opts);
1598
1792
  return phrase && " " + phrase;
1599
1793
  }
1600
1794
  function interpretDayQualifier(ir, opts) {
1795
+ if (isDayUnion(ir, opts)) {
1796
+ return "";
1797
+ }
1601
1798
  return dayQualifier(ir, leadingWords, opts) + " ";
1602
1799
  }
1603
1800
  function dayQualifier(ir, words, opts) {
@@ -1609,7 +1806,11 @@ function dayQualifier(ir, words, opts) {
1609
1806
  return datePhrase(ir, words, opts);
1610
1807
  }
1611
1808
  if (pattern.weekday !== "*") {
1612
- const weekdays = quartzWeekdayPhrase(pattern.weekday, opts) || words.weekday + weekdayPhrase(ir, opts);
1809
+ const quartzWeekday = quartzWeekdayPhrase(pattern.weekday, opts);
1810
+ if (quartzWeekday) {
1811
+ return monthScopeForRecurrence(quartzWeekday, ir, opts);
1812
+ }
1813
+ const weekdays = words.weekday + weekdayPhrase(ir, words.recurringWeekday, opts);
1613
1814
  return weekdays + monthScope(ir, opts);
1614
1815
  }
1615
1816
  if (pattern.month !== "*") {
@@ -1621,10 +1822,14 @@ function datePhrase(ir, words, opts) {
1621
1822
  const pattern = ir.pattern;
1622
1823
  const quartzDate = quartzDatePhrase(pattern.date, opts);
1623
1824
  if (quartzDate) {
1624
- return quartzDate + monthScope(ir, opts);
1825
+ return monthScopeForRecurrence(quartzDate, ir, opts);
1625
1826
  }
1626
1827
  if (isOpenStep(pattern.date)) {
1627
- return words.stepDate + stepDates(pattern.date) + monthScope(ir, opts);
1828
+ return monthScopeForRecurrence(
1829
+ words.stepDate + stepDates(pattern.date),
1830
+ ir,
1831
+ opts
1832
+ );
1628
1833
  }
1629
1834
  if (pattern.month !== "*" && !monthFoldsIntoDate(ir)) {
1630
1835
  return "on the " + dateOrdinals(ir, opts) + monthScope(ir, opts);
@@ -1640,20 +1845,105 @@ function monthFoldsIntoDate(ir) {
1640
1845
  return segment.kind !== "range";
1641
1846
  });
1642
1847
  }
1848
+ function isDayUnion(ir, opts) {
1849
+ return ir.pattern.date !== "*" && ir.pattern.weekday !== "*" && !!opts.style.untilWindow && !opts.short;
1850
+ }
1851
+ function dayUnionCondition(ir, opts) {
1852
+ const pieces = [
1853
+ ...dayUnionDatePieces(ir, opts),
1854
+ ...dayUnionWeekdayPieces(ir, opts)
1855
+ ];
1856
+ return " whenever the day is " + joinOr(pieces, opts);
1857
+ }
1858
+ function dayUnionMonthLead(ir, opts) {
1859
+ if (ir.pattern.month === "*") {
1860
+ return "";
1861
+ }
1862
+ return "in " + monthName(ir, opts) + " ";
1863
+ }
1864
+ function dayUnionDatePieces(ir, opts) {
1865
+ const dateField = ir.pattern.date;
1866
+ const quartz = quartzDatePhrase(dateField, opts);
1867
+ if (quartz) {
1868
+ return [quartz.replace(/^on /, "")];
1869
+ }
1870
+ const oddEven = oddEvenDay(dateField);
1871
+ if (oddEven) {
1872
+ return [oddEven];
1873
+ }
1874
+ const pieces = [];
1875
+ ir.analyses.segments.date.forEach(function expand(segment) {
1876
+ if (segment.kind === "range") {
1877
+ pieces.push("from the " + getOrdinal(segment.bounds[0]) + through(opts) + "the " + getOrdinal(segment.bounds[1]));
1878
+ } else if (segment.kind === "step") {
1879
+ segment.fires.forEach(function fire(value) {
1880
+ pieces.push("the " + getOrdinal(value));
1881
+ });
1882
+ } else {
1883
+ pieces.push("the " + getOrdinal(segment.value));
1884
+ }
1885
+ });
1886
+ return pieces;
1887
+ }
1888
+ function dayUnionWeekdayPieces(ir, opts) {
1889
+ const weekdayField = ir.pattern.weekday;
1890
+ const quartz = quartzWeekdayPhrase(weekdayField, opts);
1891
+ if (quartz) {
1892
+ return [quartz.replace(/^on /, "")];
1893
+ }
1894
+ const pieces = [];
1895
+ ir.analyses.segments.weekday.forEach(function expand(segment) {
1896
+ if (segment.kind === "range" && segment.bounds[0] === "1" && segment.bounds[1] === "5") {
1897
+ pieces.push("a weekday");
1898
+ } else if (segment.kind === "range") {
1899
+ pieces.push("a " + getWeekday(segment.bounds[0], opts) + through(opts) + "a " + getWeekday(segment.bounds[1], opts));
1900
+ } else if (segment.kind === "step") {
1901
+ segment.fires.forEach(function fire(value) {
1902
+ pieces.push("a " + getWeekday(value, opts));
1903
+ });
1904
+ } else {
1905
+ pieces.push("a " + getWeekday(segment.value, opts));
1906
+ }
1907
+ });
1908
+ return pieces;
1909
+ }
1910
+ function oddEvenDay(dateField) {
1911
+ if (!isOpenStep(dateField)) {
1912
+ return null;
1913
+ }
1914
+ const [start, step] = dateField.split("/");
1915
+ if (+step !== 2) {
1916
+ return null;
1917
+ }
1918
+ if (start === "*" || start === "1") {
1919
+ return "an odd-numbered day";
1920
+ }
1921
+ return start === "2" ? "an even-numbered day" : null;
1922
+ }
1643
1923
  function dateOrWeekday(ir, opts) {
1644
1924
  const pattern = ir.pattern;
1645
- const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) || "on " + weekdayPhrase(ir, opts);
1925
+ const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) || "on " + weekdayPhrase(ir, false, opts);
1926
+ if (pattern.month !== "*" && monthFoldsIntoDate(ir) && !quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
1927
+ return "on " + monthDatePhrase(ir, opts) + " or " + weekdayPart + " in " + monthName(ir, opts);
1928
+ }
1929
+ return datePart(ir, opts) + " or " + weekdayPart + orMonthScope(ir, opts);
1930
+ }
1931
+ function datePart(ir, opts) {
1932
+ const pattern = ir.pattern;
1646
1933
  const quartzDate = quartzDatePhrase(pattern.date, opts);
1647
1934
  if (quartzDate) {
1648
- return quartzDate + monthScope(ir, opts) + " or " + weekdayPart;
1935
+ return quartzDate;
1649
1936
  }
1650
1937
  if (isOpenStep(pattern.date)) {
1651
- return stepDates(pattern.date) + monthScope(ir, opts) + " or " + weekdayPart;
1938
+ return stepDates(pattern.date);
1652
1939
  }
1653
- if (pattern.month !== "*" && monthFoldsIntoDate(ir)) {
1654
- return "on " + monthDatePhrase(ir, opts) + " or " + weekdayPart + " in " + monthName(ir, opts);
1940
+ return "on the " + dateOrdinals(ir, opts);
1941
+ }
1942
+ function orMonthScope(ir, opts) {
1943
+ if (ir.pattern.month === "*") {
1944
+ return "";
1655
1945
  }
1656
- return "on the " + dateOrdinals(ir, opts) + " or " + weekdayPart + monthScope(ir, opts);
1946
+ return ", in " + monthName(ir, opts);
1657
1947
  }
1658
1948
  function quartzDatePhrase(dateField, opts) {
1659
1949
  if (dateField === "L") {
@@ -1687,6 +1977,9 @@ function monthDatePhrase(ir, opts) {
1687
1977
  opts.style.ordinals ? getOrdinal : cardinalDay,
1688
1978
  opts
1689
1979
  );
1980
+ if (opts.style.dayFirst && ir.shapes.date === "single" && ir.shapes.month !== "single") {
1981
+ return "the " + getOrdinal(ir.pattern.date) + " of " + month;
1982
+ }
1690
1983
  return opts.style.dayFirst ? days + " " + month : month + " " + days;
1691
1984
  }
1692
1985
  function cardinalDay(value) {
@@ -1698,6 +1991,19 @@ function monthScope(ir, opts) {
1698
1991
  }
1699
1992
  return " in " + monthName(ir, opts);
1700
1993
  }
1994
+ function monthScopeForRecurrence(phrase, ir, opts) {
1995
+ if (ir.pattern.month === "*") {
1996
+ return phrase;
1997
+ }
1998
+ const carriesRecurrence = phrase.indexOf(" of the month") !== -1;
1999
+ if (carriesRecurrence && ir.shapes.month === "range") {
2000
+ return phrase.replace(" of the month", " of each month") + " from " + monthName(ir, opts);
2001
+ }
2002
+ if (carriesRecurrence && (ir.shapes.month === "single" || ir.shapes.month === "step")) {
2003
+ return phrase.replace(" of the month", "") + " in " + monthName(ir, opts);
2004
+ }
2005
+ return phrase + " in " + monthName(ir, opts);
2006
+ }
1701
2007
  function stepDates(dateField) {
1702
2008
  const parts = dateField.split("/");
1703
2009
  const interval = +parts[1];
@@ -1734,10 +2040,21 @@ function oddEvenMonth(monthField) {
1734
2040
  }
1735
2041
  return start === "2" ? "every even-numbered month" : null;
1736
2042
  }
1737
- function weekdayPhrase(ir, opts) {
1738
- return renderSegments(ir.analyses.segments.weekday, function name(value) {
2043
+ function weekdayPhrase(ir, recurring, opts) {
2044
+ const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday);
2045
+ const hasRange = segments.some(function range(segment) {
2046
+ return segment.kind === "range";
2047
+ });
2048
+ const name = recurring && !hasRange ? function plural(value) {
2049
+ return pluralWeekday(value, opts);
2050
+ } : function singular(value) {
1739
2051
  return getWeekday(value, opts);
1740
- }, opts);
2052
+ };
2053
+ return renderSegments(segments, name, opts);
2054
+ }
2055
+ function pluralWeekday(value, opts) {
2056
+ const name = getWeekday(value, opts);
2057
+ return opts.short ? name : name + "s";
1741
2058
  }
1742
2059
  function renderSegments(segments, word, opts) {
1743
2060
  const pieces = [];
@@ -1761,7 +2078,7 @@ function applyYear(description, ir, opts) {
1761
2078
  return description;
1762
2079
  }
1763
2080
  if (yearField.indexOf("/") !== -1) {
1764
- return description + " " + stepYears(yearField, opts);
2081
+ return description + ", " + stepYears(yearField, opts);
1765
2082
  }
1766
2083
  const label = yearLabel(yearField, opts);
1767
2084
  if (yearField.indexOf("-") === -1 && yearField.indexOf(",") === -1 && ir.pattern.date !== "*" && description.indexOf(" at ") !== -1) {
@@ -1774,6 +2091,9 @@ function yearLabel(yearField, opts) {
1774
2091
  if (yearField.indexOf(",") !== -1) {
1775
2092
  return joinList(yearField.split(","), opts);
1776
2093
  }
2094
+ if (yearField.indexOf("-") !== -1) {
2095
+ return yearField.split("-").join(through(opts));
2096
+ }
1777
2097
  return yearField;
1778
2098
  }
1779
2099
  function stepYears(yearField, opts) {
@@ -1783,7 +2103,7 @@ function stepYears(yearField, opts) {
1783
2103
  if (interval <= 1) {
1784
2104
  return "every year";
1785
2105
  }
1786
- let phrase = "every " + getNumber(interval, opts) + " years";
2106
+ let phrase = interval === 2 ? "every other year" : "every " + getNumber(interval, opts) + " years";
1787
2107
  if (start !== "*" && start !== "0") {
1788
2108
  phrase += " from " + start;
1789
2109
  }