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.js CHANGED
@@ -84,6 +84,26 @@ function arithmeticStep(values) {
84
84
  }
85
85
  return { start: values[0], interval, last: values[values.length - 1] };
86
86
  }
87
+ function weekdayDisplayKey(value) {
88
+ return value === 0 ? 7 : value;
89
+ }
90
+ function orderWeekdaysForDisplay(segments) {
91
+ const flattened = segments.flatMap(function flat(segment) {
92
+ return segment.kind === "step" ? segment.fires.map(function single(value) {
93
+ return { kind: "single", value: "" + value };
94
+ }) : [segment];
95
+ });
96
+ function key(segment) {
97
+ return segment.kind === "range" ? weekdayDisplayKey(+segment.bounds[0]) : weekdayDisplayKey(+segment.value);
98
+ }
99
+ return flattened.map(function index(segment, position) {
100
+ return [segment, position];
101
+ }).sort(function byDisplayKey(a, b) {
102
+ return key(a[0]) - key(b[0]) || a[1] - b[1];
103
+ }).map(function unwrap(pair) {
104
+ return pair[0];
105
+ });
106
+ }
87
107
  function toFieldNumber(token, numberMap) {
88
108
  return isNonNegativeInteger(token) ? +token : numberMap[token.toUpperCase()];
89
109
  }
@@ -829,7 +849,8 @@ var dialects = {
829
849
  pm: "p.m.",
830
850
  sep: ":",
831
851
  serialComma: true,
832
- through: " through "
852
+ through: " through ",
853
+ untilWindow: true
833
854
  },
834
855
  house: {
835
856
  am: "AM",
@@ -846,7 +867,7 @@ var dialects = {
846
867
  };
847
868
  function resolveDialect(dialect) {
848
869
  if (typeof dialect === "object" && dialect !== null) {
849
- return { ...dialects.us, ...dialect };
870
+ return { ...dialects.us, untilWindow: false, ...dialect };
850
871
  }
851
872
  const name = dialect === "uk" ? "gb" : dialect;
852
873
  return dialects[name] || dialects.us;
@@ -918,7 +939,9 @@ function normalizeOptions(options) {
918
939
  };
919
940
  }
920
941
  function describe(ir, opts) {
921
- return applyYear(render(ir, ir.plan, opts), ir, opts);
942
+ const body = confinement(ir, opts) ?? render(ir, ir.plan, opts);
943
+ const lead = isDayUnion(ir, opts) ? dayUnionMonthLead(ir, opts) : "";
944
+ return applyYear(lead + body, ir, opts);
922
945
  }
923
946
  function render(ir, plan, opts) {
924
947
  const renderer = renderers[plan.kind];
@@ -1011,7 +1034,7 @@ function secondsClause(ir, anchor, opts) {
1011
1034
  }
1012
1035
  if (shape === "range") {
1013
1036
  const bounds = secondField.split("-");
1014
- const num = seriesNumber(bounds, opts);
1037
+ const num = seriesNumber();
1015
1038
  return "every second from " + num(bounds[0]) + through(opts) + num(bounds[1]) + " past the " + anchor;
1016
1039
  }
1017
1040
  if (shape === "single") {
@@ -1077,15 +1100,21 @@ function renderMinutesAcrossHours(ir, plan, opts) {
1077
1100
  }
1078
1101
  return "every minute during the " + hourTimesFromPlan(ir, plan.times, false, opts) + " hours" + trailingQualifier(ir, opts);
1079
1102
  }
1080
- const lead = plan.form === "range" ? minuteRangeLead(ir.pattern.minute, opts) : (
1081
- // The 'list' form is a minute list, which has segments; an offset/uneven
1082
- // step enumerated to that list reads as a stride.
1083
- strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts) ?? listPastThe(
1084
- segmentWords(ir.analyses.segments.minute, opts),
1085
- "minute",
1086
- "hour",
1087
- opts
1088
- )
1103
+ if (plan.form === "range") {
1104
+ const lead2 = minuteRangeLead(ir.pattern.minute, opts);
1105
+ if (cadence !== null) {
1106
+ return lead2 + ", " + cadence + trailingQualifier(ir, opts);
1107
+ }
1108
+ if (singleHourFire(plan.times)) {
1109
+ return lead2 + ", at " + hourTimesFromPlan(ir, plan.times, true, opts) + trailingQualifier(ir, opts);
1110
+ }
1111
+ return lead2 + " during the " + hourTimesFromPlan(ir, plan.times, false, opts) + " hours" + trailingQualifier(ir, opts);
1112
+ }
1113
+ const lead = strideFromSegments(ir.analyses.segments.minute, "minute", "hour", opts) ?? listPastThe(
1114
+ segmentWords(ir.analyses.segments.minute, opts),
1115
+ "minute",
1116
+ "hour",
1117
+ opts
1089
1118
  );
1090
1119
  if (cadence !== null) {
1091
1120
  return lead + ", " + cadence + trailingQualifier(ir, opts);
@@ -1122,7 +1151,7 @@ function renderMinuteSpanAcrossHourStep(ir, plan, opts) {
1122
1151
  }
1123
1152
  function minuteRangeLead(minuteField, opts) {
1124
1153
  const bounds = minuteField.split("-");
1125
- const num = seriesNumber(bounds, opts);
1154
+ const num = seriesNumber();
1126
1155
  return "every minute from " + num(bounds[0]) + through(opts) + num(bounds[1]) + " past the hour";
1127
1156
  }
1128
1157
  function renderEveryHour(ir, plan, opts) {
@@ -1162,10 +1191,18 @@ function renderHourStep(ir, plan, opts) {
1162
1191
  return stepHours(ir.analyses.segments.hour[0], opts) + trailingQualifier(ir, opts);
1163
1192
  }
1164
1193
  function boundedWindow(plan) {
1165
- return { from: plan.from, last: plan.boundMinute ?? 0, to: plan.to };
1194
+ const last = plan.minuteForm === "wildcard" ? plan.boundMinute ?? 0 : 0;
1195
+ return { from: plan.from, last, to: plan.to };
1196
+ }
1197
+ function rangeWindow(from, to, throughMinute, opts) {
1198
+ const open = "from " + getTime({ hour: from, minute: 0 }, opts);
1199
+ if (opts.style.untilWindow && !opts.short && from !== to) {
1200
+ return open + " until " + getTime({ hour: (to + 1) % 24, minute: 0 }, opts);
1201
+ }
1202
+ return open + through(opts) + getTime({ hour: to, minute: throughMinute }, opts);
1166
1203
  }
1167
1204
  function hourWindow(window, opts) {
1168
- return "from " + getTime({ hour: window.from, minute: 0 }, opts) + through(opts) + getTime({ hour: window.to, minute: window.last }, opts);
1205
+ return rangeWindow(window.from, window.to, window.last, opts);
1169
1206
  }
1170
1207
  function renderClockTimes(ir, plan, opts) {
1171
1208
  if (ir.shapes.minute === "single") {
@@ -1184,7 +1221,10 @@ function renderClockTimes(ir, plan, opts) {
1184
1221
  plain
1185
1222
  }, opts);
1186
1223
  });
1187
- return interpretDayQualifier(ir, opts) + "at " + joinList(times, opts);
1224
+ return interpretDayQualifier(ir, opts) + "at " + joinList(times, opts) + dayUnionTrail(ir, opts);
1225
+ }
1226
+ function dayUnionTrail(ir, opts) {
1227
+ return isDayUnion(ir, opts) ? dayUnionCondition(ir, opts) : "";
1188
1228
  }
1189
1229
  function renderCompactClockTimes(ir, plan, opts) {
1190
1230
  if (plan.fold) {
@@ -1199,7 +1239,7 @@ function renderCompactClockTimes(ir, plan, opts) {
1199
1239
  return foldedHourWindows(ir, plan, opts) + trailingQualifier(ir, opts);
1200
1240
  }
1201
1241
  const fold = { minute: plan.minute, second: ir.analyses.clockSecond };
1202
- return interpretDayQualifier(ir, opts) + "at " + hourSegmentTimes(ir, fold, true, opts);
1242
+ return interpretDayQualifier(ir, opts) + "at " + hourSegmentTimes(ir, fold, true, opts) + dayUnionTrail(ir, opts);
1203
1243
  }
1204
1244
  const minuteLead = (
1205
1245
  // The non-fold branch is a minute list, which has segments. An
@@ -1218,26 +1258,161 @@ function renderCompactClockTimes(ir, plan, opts) {
1218
1258
  function foldedHourWindows(ir, plan, opts) {
1219
1259
  const minute = plan.minute;
1220
1260
  const windows = [];
1221
- const singles = [];
1261
+ const outliers = collectHourOutliers(ir);
1262
+ const times = outliers.hours.map(function time(hour) {
1263
+ return getTime({ hour, minute }, opts);
1264
+ });
1222
1265
  ir.analyses.segments.hour.forEach(function classify(segment) {
1223
1266
  if (segment.kind === "range") {
1224
- windows.push("from " + getTime(
1225
- { hour: segment.bounds[0], minute: 0 },
1267
+ windows.push(rangeWindow(
1268
+ +segment.bounds[0],
1269
+ +segment.bounds[1],
1270
+ minute,
1226
1271
  opts
1227
- ) + through(opts) + getTime({ hour: segment.bounds[1], minute }, opts));
1228
- } else if (segment.kind === "step") {
1229
- singles.push(...segment.fires);
1230
- } else {
1231
- singles.push(+segment.value);
1272
+ ));
1273
+ }
1274
+ });
1275
+ const phrase = rangeMinuteLead(ir, opts) + " " + joinList(windows, opts);
1276
+ return phrase + outlierTail(times, outliers.pureStrays, opts);
1277
+ }
1278
+ function collectHourOutliers(ir) {
1279
+ const hours = [];
1280
+ let pureStrays = true;
1281
+ ir.analyses.segments.hour.forEach(function classify(segment) {
1282
+ if (segment.kind === "step") {
1283
+ hours.push(...segment.fires);
1284
+ pureStrays = false;
1285
+ } else if (segment.kind !== "range") {
1286
+ hours.push(+segment.value);
1232
1287
  }
1233
1288
  });
1234
- let phrase = rangeMinuteLead(ir, opts) + " " + joinList(windows, opts);
1235
- if (singles.length) {
1236
- phrase += " and at " + joinList(singles.map(function time(hour) {
1237
- return getTime({ hour, minute }, opts);
1238
- }), opts);
1289
+ return { hours, pureStrays };
1290
+ }
1291
+ function outlierTail(times, pureStrays, opts) {
1292
+ if (!times.length) {
1293
+ return "";
1239
1294
  }
1240
- return phrase;
1295
+ const connector = pureStrays && opts.style.untilWindow && !opts.short ? " plus " : " and at ";
1296
+ return connector + joinList(times, opts);
1297
+ }
1298
+ function isCadenceField(token) {
1299
+ return token === "*" || token.startsWith("*/") && token.indexOf("-") === -1;
1300
+ }
1301
+ function leadingCadence(ir, opts) {
1302
+ const { second, minute } = ir.pattern;
1303
+ if (isCadenceField(second)) {
1304
+ return { secondLead: true, text: secondsClause(ir, "minute", opts) };
1305
+ }
1306
+ if (second === "0" && isCadenceField(minute)) {
1307
+ const text = minute === "*" ? "every minute" : (
1308
+ // A clean minute step's first segment is a step segment.
1309
+ stepCycle60(
1310
+ ir.analyses.segments.minute[0],
1311
+ "minute",
1312
+ "hour",
1313
+ opts
1314
+ )
1315
+ );
1316
+ return { secondLead: false, text };
1317
+ }
1318
+ return null;
1319
+ }
1320
+ function minuteConfinement(ir, opts) {
1321
+ const minute = ir.pattern.minute;
1322
+ if (minute === "*") {
1323
+ return "";
1324
+ }
1325
+ if (isCadenceField(minute)) {
1326
+ return " of every other minute";
1327
+ }
1328
+ const segments = ir.analyses.segments.minute;
1329
+ if (ir.shapes.minute === "single") {
1330
+ return " during minute :" + pad(minute);
1331
+ }
1332
+ if (ir.shapes.minute === "range") {
1333
+ const bounds = minute.split("-");
1334
+ return " during minutes :" + pad(bounds[0]) + through(opts) + ":" + pad(bounds[1]);
1335
+ }
1336
+ const values = segmentWords(segments, opts).map(function colon(word) {
1337
+ return ":" + pad(word);
1338
+ });
1339
+ return " during minutes " + joinList(values, opts);
1340
+ }
1341
+ function hourConfinement(ir, opts) {
1342
+ const hour = ir.pattern.hour;
1343
+ if (hour === "*") {
1344
+ const minutePinned = ir.pattern.minute !== "*" && !isCadenceField(ir.pattern.minute);
1345
+ return minutePinned ? " of every hour" : "";
1346
+ }
1347
+ if (isCadenceField(hour)) {
1348
+ return hour === "*/2" ? " of every other hour" : "";
1349
+ }
1350
+ if (ir.shapes.hour === "single") {
1351
+ const h = +hour;
1352
+ if (ir.shapes.minute === "step") {
1353
+ return " from " + getTime({ hour: h, minute: 0 }, opts) + " until " + getTime({ hour: (h + 1) % 24, minute: 0 }, opts);
1354
+ }
1355
+ if (ir.pattern.minute !== "*" && !isCadenceField(ir.pattern.minute)) {
1356
+ return " at " + getTime({ hour: h, minute: 0 }, opts);
1357
+ }
1358
+ return " of the " + getTime({ hour: h, minute: 0 }, opts) + " hour";
1359
+ }
1360
+ if (ir.shapes.hour === "range") {
1361
+ const bounds = hour.split("-");
1362
+ return " " + rangeWindow(+bounds[0], +bounds[1], 0, opts);
1363
+ }
1364
+ return " during the " + hourSegmentTimes(ir, { minute: 0, second: null }, false, opts) + " hours";
1365
+ }
1366
+ function isContiguousHourRange(ir) {
1367
+ return ir.shapes.hour === "range";
1368
+ }
1369
+ function confinableHour(ir) {
1370
+ if (ir.shapes.hour !== "step") {
1371
+ return true;
1372
+ }
1373
+ const segment = ir.analyses.segments.hour[0];
1374
+ return ir.pattern.hour === "*/2" || segment.startToken.indexOf("-") !== -1;
1375
+ }
1376
+ function isMinuteStride(ir) {
1377
+ if (ir.shapes.minute !== "list") {
1378
+ return false;
1379
+ }
1380
+ const values = singleValues(ir.analyses.segments.minute);
1381
+ return values !== null && arithmeticStep(values) !== null;
1382
+ }
1383
+ function confinementEligible(ir, lead) {
1384
+ const { minute, hour } = ir.pattern;
1385
+ const minuteStep = isCadenceField(minute) && minute !== "*";
1386
+ if (!confinableHour(ir)) {
1387
+ return false;
1388
+ }
1389
+ if (lead.secondLead) {
1390
+ if (minuteStep) {
1391
+ return minute === "*/2" && !isContiguousHourRange(ir);
1392
+ }
1393
+ if (isMinuteStride(ir) || ir.shapes.minute === "list" && ir.shapes.hour === "list") {
1394
+ return false;
1395
+ }
1396
+ return true;
1397
+ }
1398
+ if (hour === "*/2") {
1399
+ return true;
1400
+ }
1401
+ return ir.shapes.hour === "single" && minute === "*/2";
1402
+ }
1403
+ function confinement(ir, opts) {
1404
+ if (!opts.style.untilWindow || opts.short) {
1405
+ return null;
1406
+ }
1407
+ if (ir.pattern.minute === "*" && ir.pattern.hour === "*") {
1408
+ return null;
1409
+ }
1410
+ const lead = leadingCadence(ir, opts);
1411
+ if (!lead || !confinementEligible(ir, lead)) {
1412
+ return null;
1413
+ }
1414
+ const minutePart = lead.secondLead ? minuteConfinement(ir, opts) : "";
1415
+ return lead.text + minutePart + hourConfinement(ir, opts) + trailingQualifier(ir, opts);
1241
1416
  }
1242
1417
  var renderers = {
1243
1418
  clockTimes: renderClockTimes,
@@ -1269,7 +1444,7 @@ function renderStride(stride, opts) {
1269
1444
  if (start < interval && tiles) {
1270
1445
  return cadence + " from " + getNumber(start, opts) + " " + pluralize(start, unit) + " past the " + anchor;
1271
1446
  }
1272
- const num = seriesNumber([start, last], opts);
1447
+ const num = seriesNumber();
1273
1448
  return cadence + " from " + num(start) + through(opts) + num(last) + " " + pluralize(last, unit) + " past the " + anchor;
1274
1449
  }
1275
1450
  function singleValues(segments) {
@@ -1396,9 +1571,9 @@ function hourCadence(ir, minute, opts) {
1396
1571
  if (ir.pattern.second === "0" && fires <= maxClockTimes && offsetCleanStride(stride)) {
1397
1572
  return null;
1398
1573
  }
1399
- const confinement = minute === 0 && subMinuteSecond(ir) && cleanStrideSegment(ir);
1400
- if (confinement) {
1401
- return secondsClause(ir, "minute", opts) + " for one minute " + everyNthHour(confinement, opts) + trailingQualifier(ir, opts);
1574
+ const minuteZeroStride = minute === 0 && subMinuteSecond(ir) && cleanStrideSegment(ir);
1575
+ if (minuteZeroStride) {
1576
+ return secondsClause(ir, "minute", opts) + " for one minute " + everyNthHour(minuteZeroStride, opts) + trailingQualifier(ir, opts);
1402
1577
  }
1403
1578
  if (minute === 0 && ir.pattern.second === "0") {
1404
1579
  return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
@@ -1420,26 +1595,22 @@ function hasHourWindow(ir) {
1420
1595
  }
1421
1596
  function hourRangeWindowTail(ir, opts) {
1422
1597
  const windows = [];
1423
- const singles = [];
1598
+ const outliers = collectHourOutliers(ir);
1424
1599
  ir.analyses.segments.hour.forEach(function classify(segment) {
1425
1600
  if (segment.kind === "range") {
1426
- windows.push("from " + getTime(
1427
- { hour: +segment.bounds[0], minute: 0 },
1601
+ windows.push(rangeWindow(
1602
+ +segment.bounds[0],
1603
+ +segment.bounds[1],
1604
+ 0,
1428
1605
  opts
1429
- ) + through(opts) + getTime({ hour: +segment.bounds[1], minute: 0 }, opts));
1430
- } else if (segment.kind === "step") {
1431
- singles.push(...segment.fires);
1432
- } else {
1433
- singles.push(+segment.value);
1606
+ ));
1434
1607
  }
1435
1608
  });
1436
- let phrase = "every hour " + joinList(windows, opts);
1437
- if (singles.length) {
1438
- phrase += " and at " + joinList(singles.map(function time(hour) {
1439
- return getTime({ hour, minute: 0 }, opts);
1440
- }), opts);
1441
- }
1442
- return phrase;
1609
+ const phrase = "every hour " + joinList(windows, opts);
1610
+ const times = outliers.hours.map(function time(hour) {
1611
+ return getTime({ hour, minute: 0 }, opts);
1612
+ });
1613
+ return phrase + outlierTail(times, outliers.pureStrays, opts);
1443
1614
  }
1444
1615
  function hourRangeCadence(ir, minute, opts) {
1445
1616
  if (minute !== 0 || !hasHourWindow(ir)) {
@@ -1453,25 +1624,29 @@ function hourRangeCadence(ir, minute, opts) {
1453
1624
  }
1454
1625
  return hourCadenceLead(ir, minute, opts) + ", " + hourRangeWindowTail(ir, opts) + trailingQualifier(ir, opts);
1455
1626
  }
1456
- function seriesNumber(values, opts) {
1457
- const anyBig = values.some(function big(v) {
1458
- return +v > 10;
1459
- });
1627
+ function seriesNumber() {
1460
1628
  return function format(n) {
1461
- return anyBig ? "" + n : getNumber(n, opts);
1629
+ return "" + n;
1630
+ };
1631
+ }
1632
+ function listNumber(count, opts) {
1633
+ return count > 1 ? function asNumeral(n) {
1634
+ return "" + n;
1635
+ } : function spelled(n) {
1636
+ return getNumber(n, opts);
1462
1637
  };
1463
1638
  }
1464
1639
  function numberWords(fires, opts) {
1465
- return fires.map(seriesNumber(fires, opts));
1640
+ return fires.map(listNumber(fires.length, opts));
1466
1641
  }
1467
1642
  function segmentWords(segments, opts) {
1468
- const values = segments.flatMap(function collect(segment) {
1643
+ const count = segments.reduce(function tally(sum, segment) {
1469
1644
  if (segment.kind === "range") {
1470
- return segment.bounds;
1645
+ return sum + 1;
1471
1646
  }
1472
- return segment.kind === "step" ? segment.fires : [segment.value];
1473
- });
1474
- const num = seriesNumber(values, opts);
1647
+ return sum + (segment.kind === "step" ? segment.fires.length : 1);
1648
+ }, 0);
1649
+ const num = listNumber(count, opts);
1475
1650
  return segments.flatMap(function word(segment) {
1476
1651
  if (segment.kind === "range") {
1477
1652
  return [num(segment.bounds[0]) + through(opts) + num(segment.bounds[1])];
@@ -1503,6 +1678,9 @@ function hourTimes(hours, opts) {
1503
1678
  });
1504
1679
  return joinList(times, opts);
1505
1680
  }
1681
+ function singleHourFire(times) {
1682
+ return times.kind === "fires" && times.fires.length === 1;
1683
+ }
1506
1684
  function hourTimesFromPlan(ir, times, atContext, opts) {
1507
1685
  if (times.kind === "fires") {
1508
1686
  return hourTimes(times.fires, opts);
@@ -1550,28 +1728,47 @@ function disambiguateTimes(pieces, segments, atContext) {
1550
1728
  return index === 0 ? piece : "at " + piece;
1551
1729
  });
1552
1730
  }
1553
- function joinList(items, opts) {
1731
+ function joinWith(items, conjunction, opts) {
1554
1732
  if (items.length <= 1) {
1555
1733
  return items.join("");
1556
1734
  }
1557
1735
  if (items.length === 2) {
1558
- return items[0] + " and " + items[1];
1736
+ return items[0] + conjunction + items[1];
1559
1737
  }
1560
- const and = opts.style.serialComma ? ", and " : " and ";
1561
- return items.slice(0, -1).join(", ") + and + items[items.length - 1];
1738
+ const tail = opts.style.serialComma ? "," + conjunction : conjunction;
1739
+ return items.slice(0, -1).join(", ") + tail + items[items.length - 1];
1740
+ }
1741
+ function joinList(items, opts) {
1742
+ return joinWith(items, " and ", opts);
1743
+ }
1744
+ function joinOr(items, opts) {
1745
+ return joinWith(items, " or ", opts);
1562
1746
  }
1563
- var trailingWords = { all: "", month: "in ", stepDate: "on ", weekday: "on " };
1747
+ var trailingWords = {
1748
+ all: "",
1749
+ month: "in ",
1750
+ recurringWeekday: true,
1751
+ stepDate: "on ",
1752
+ weekday: "on "
1753
+ };
1564
1754
  var leadingWords = {
1565
1755
  all: "every day",
1566
1756
  month: "every day in ",
1757
+ recurringWeekday: false,
1567
1758
  stepDate: "",
1568
1759
  weekday: "every "
1569
1760
  };
1570
1761
  function trailingQualifier(ir, opts) {
1762
+ if (isDayUnion(ir, opts)) {
1763
+ return dayUnionCondition(ir, opts);
1764
+ }
1571
1765
  const phrase = dayQualifier(ir, trailingWords, opts);
1572
1766
  return phrase && " " + phrase;
1573
1767
  }
1574
1768
  function interpretDayQualifier(ir, opts) {
1769
+ if (isDayUnion(ir, opts)) {
1770
+ return "";
1771
+ }
1575
1772
  return dayQualifier(ir, leadingWords, opts) + " ";
1576
1773
  }
1577
1774
  function dayQualifier(ir, words, opts) {
@@ -1583,7 +1780,11 @@ function dayQualifier(ir, words, opts) {
1583
1780
  return datePhrase(ir, words, opts);
1584
1781
  }
1585
1782
  if (pattern.weekday !== "*") {
1586
- const weekdays = quartzWeekdayPhrase(pattern.weekday, opts) || words.weekday + weekdayPhrase(ir, opts);
1783
+ const quartzWeekday = quartzWeekdayPhrase(pattern.weekday, opts);
1784
+ if (quartzWeekday) {
1785
+ return monthScopeForRecurrence(quartzWeekday, ir, opts);
1786
+ }
1787
+ const weekdays = words.weekday + weekdayPhrase(ir, words.recurringWeekday, opts);
1587
1788
  return weekdays + monthScope(ir, opts);
1588
1789
  }
1589
1790
  if (pattern.month !== "*") {
@@ -1595,10 +1796,14 @@ function datePhrase(ir, words, opts) {
1595
1796
  const pattern = ir.pattern;
1596
1797
  const quartzDate = quartzDatePhrase(pattern.date, opts);
1597
1798
  if (quartzDate) {
1598
- return quartzDate + monthScope(ir, opts);
1799
+ return monthScopeForRecurrence(quartzDate, ir, opts);
1599
1800
  }
1600
1801
  if (isOpenStep(pattern.date)) {
1601
- return words.stepDate + stepDates(pattern.date) + monthScope(ir, opts);
1802
+ return monthScopeForRecurrence(
1803
+ words.stepDate + stepDates(pattern.date),
1804
+ ir,
1805
+ opts
1806
+ );
1602
1807
  }
1603
1808
  if (pattern.month !== "*" && !monthFoldsIntoDate(ir)) {
1604
1809
  return "on the " + dateOrdinals(ir, opts) + monthScope(ir, opts);
@@ -1614,20 +1819,105 @@ function monthFoldsIntoDate(ir) {
1614
1819
  return segment.kind !== "range";
1615
1820
  });
1616
1821
  }
1822
+ function isDayUnion(ir, opts) {
1823
+ return ir.pattern.date !== "*" && ir.pattern.weekday !== "*" && !!opts.style.untilWindow && !opts.short;
1824
+ }
1825
+ function dayUnionCondition(ir, opts) {
1826
+ const pieces = [
1827
+ ...dayUnionDatePieces(ir, opts),
1828
+ ...dayUnionWeekdayPieces(ir, opts)
1829
+ ];
1830
+ return " whenever the day is " + joinOr(pieces, opts);
1831
+ }
1832
+ function dayUnionMonthLead(ir, opts) {
1833
+ if (ir.pattern.month === "*") {
1834
+ return "";
1835
+ }
1836
+ return "in " + monthName(ir, opts) + " ";
1837
+ }
1838
+ function dayUnionDatePieces(ir, opts) {
1839
+ const dateField = ir.pattern.date;
1840
+ const quartz = quartzDatePhrase(dateField, opts);
1841
+ if (quartz) {
1842
+ return [quartz.replace(/^on /, "")];
1843
+ }
1844
+ const oddEven = oddEvenDay(dateField);
1845
+ if (oddEven) {
1846
+ return [oddEven];
1847
+ }
1848
+ const pieces = [];
1849
+ ir.analyses.segments.date.forEach(function expand(segment) {
1850
+ if (segment.kind === "range") {
1851
+ pieces.push("from the " + getOrdinal(segment.bounds[0]) + through(opts) + "the " + getOrdinal(segment.bounds[1]));
1852
+ } else if (segment.kind === "step") {
1853
+ segment.fires.forEach(function fire(value) {
1854
+ pieces.push("the " + getOrdinal(value));
1855
+ });
1856
+ } else {
1857
+ pieces.push("the " + getOrdinal(segment.value));
1858
+ }
1859
+ });
1860
+ return pieces;
1861
+ }
1862
+ function dayUnionWeekdayPieces(ir, opts) {
1863
+ const weekdayField = ir.pattern.weekday;
1864
+ const quartz = quartzWeekdayPhrase(weekdayField, opts);
1865
+ if (quartz) {
1866
+ return [quartz.replace(/^on /, "")];
1867
+ }
1868
+ const pieces = [];
1869
+ ir.analyses.segments.weekday.forEach(function expand(segment) {
1870
+ if (segment.kind === "range" && segment.bounds[0] === "1" && segment.bounds[1] === "5") {
1871
+ pieces.push("a weekday");
1872
+ } else if (segment.kind === "range") {
1873
+ pieces.push("a " + getWeekday(segment.bounds[0], opts) + through(opts) + "a " + getWeekday(segment.bounds[1], opts));
1874
+ } else if (segment.kind === "step") {
1875
+ segment.fires.forEach(function fire(value) {
1876
+ pieces.push("a " + getWeekday(value, opts));
1877
+ });
1878
+ } else {
1879
+ pieces.push("a " + getWeekday(segment.value, opts));
1880
+ }
1881
+ });
1882
+ return pieces;
1883
+ }
1884
+ function oddEvenDay(dateField) {
1885
+ if (!isOpenStep(dateField)) {
1886
+ return null;
1887
+ }
1888
+ const [start, step] = dateField.split("/");
1889
+ if (+step !== 2) {
1890
+ return null;
1891
+ }
1892
+ if (start === "*" || start === "1") {
1893
+ return "an odd-numbered day";
1894
+ }
1895
+ return start === "2" ? "an even-numbered day" : null;
1896
+ }
1617
1897
  function dateOrWeekday(ir, opts) {
1618
1898
  const pattern = ir.pattern;
1619
- const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) || "on " + weekdayPhrase(ir, opts);
1899
+ const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) || "on " + weekdayPhrase(ir, false, opts);
1900
+ if (pattern.month !== "*" && monthFoldsIntoDate(ir) && !quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
1901
+ return "on " + monthDatePhrase(ir, opts) + " or " + weekdayPart + " in " + monthName(ir, opts);
1902
+ }
1903
+ return datePart(ir, opts) + " or " + weekdayPart + orMonthScope(ir, opts);
1904
+ }
1905
+ function datePart(ir, opts) {
1906
+ const pattern = ir.pattern;
1620
1907
  const quartzDate = quartzDatePhrase(pattern.date, opts);
1621
1908
  if (quartzDate) {
1622
- return quartzDate + monthScope(ir, opts) + " or " + weekdayPart;
1909
+ return quartzDate;
1623
1910
  }
1624
1911
  if (isOpenStep(pattern.date)) {
1625
- return stepDates(pattern.date) + monthScope(ir, opts) + " or " + weekdayPart;
1912
+ return stepDates(pattern.date);
1626
1913
  }
1627
- if (pattern.month !== "*" && monthFoldsIntoDate(ir)) {
1628
- return "on " + monthDatePhrase(ir, opts) + " or " + weekdayPart + " in " + monthName(ir, opts);
1914
+ return "on the " + dateOrdinals(ir, opts);
1915
+ }
1916
+ function orMonthScope(ir, opts) {
1917
+ if (ir.pattern.month === "*") {
1918
+ return "";
1629
1919
  }
1630
- return "on the " + dateOrdinals(ir, opts) + " or " + weekdayPart + monthScope(ir, opts);
1920
+ return ", in " + monthName(ir, opts);
1631
1921
  }
1632
1922
  function quartzDatePhrase(dateField, opts) {
1633
1923
  if (dateField === "L") {
@@ -1661,6 +1951,9 @@ function monthDatePhrase(ir, opts) {
1661
1951
  opts.style.ordinals ? getOrdinal : cardinalDay,
1662
1952
  opts
1663
1953
  );
1954
+ if (opts.style.dayFirst && ir.shapes.date === "single" && ir.shapes.month !== "single") {
1955
+ return "the " + getOrdinal(ir.pattern.date) + " of " + month;
1956
+ }
1664
1957
  return opts.style.dayFirst ? days + " " + month : month + " " + days;
1665
1958
  }
1666
1959
  function cardinalDay(value) {
@@ -1672,6 +1965,19 @@ function monthScope(ir, opts) {
1672
1965
  }
1673
1966
  return " in " + monthName(ir, opts);
1674
1967
  }
1968
+ function monthScopeForRecurrence(phrase, ir, opts) {
1969
+ if (ir.pattern.month === "*") {
1970
+ return phrase;
1971
+ }
1972
+ const carriesRecurrence = phrase.indexOf(" of the month") !== -1;
1973
+ if (carriesRecurrence && ir.shapes.month === "range") {
1974
+ return phrase.replace(" of the month", " of each month") + " from " + monthName(ir, opts);
1975
+ }
1976
+ if (carriesRecurrence && (ir.shapes.month === "single" || ir.shapes.month === "step")) {
1977
+ return phrase.replace(" of the month", "") + " in " + monthName(ir, opts);
1978
+ }
1979
+ return phrase + " in " + monthName(ir, opts);
1980
+ }
1675
1981
  function stepDates(dateField) {
1676
1982
  const parts = dateField.split("/");
1677
1983
  const interval = +parts[1];
@@ -1708,10 +2014,21 @@ function oddEvenMonth(monthField) {
1708
2014
  }
1709
2015
  return start === "2" ? "every even-numbered month" : null;
1710
2016
  }
1711
- function weekdayPhrase(ir, opts) {
1712
- return renderSegments(ir.analyses.segments.weekday, function name(value) {
2017
+ function weekdayPhrase(ir, recurring, opts) {
2018
+ const segments = orderWeekdaysForDisplay(ir.analyses.segments.weekday);
2019
+ const hasRange = segments.some(function range(segment) {
2020
+ return segment.kind === "range";
2021
+ });
2022
+ const name = recurring && !hasRange ? function plural(value) {
2023
+ return pluralWeekday(value, opts);
2024
+ } : function singular(value) {
1713
2025
  return getWeekday(value, opts);
1714
- }, opts);
2026
+ };
2027
+ return renderSegments(segments, name, opts);
2028
+ }
2029
+ function pluralWeekday(value, opts) {
2030
+ const name = getWeekday(value, opts);
2031
+ return opts.short ? name : name + "s";
1715
2032
  }
1716
2033
  function renderSegments(segments, word, opts) {
1717
2034
  const pieces = [];
@@ -1735,7 +2052,7 @@ function applyYear(description, ir, opts) {
1735
2052
  return description;
1736
2053
  }
1737
2054
  if (yearField.indexOf("/") !== -1) {
1738
- return description + " " + stepYears(yearField, opts);
2055
+ return description + ", " + stepYears(yearField, opts);
1739
2056
  }
1740
2057
  const label = yearLabel(yearField, opts);
1741
2058
  if (yearField.indexOf("-") === -1 && yearField.indexOf(",") === -1 && ir.pattern.date !== "*" && description.indexOf(" at ") !== -1) {
@@ -1748,6 +2065,9 @@ function yearLabel(yearField, opts) {
1748
2065
  if (yearField.indexOf(",") !== -1) {
1749
2066
  return joinList(yearField.split(","), opts);
1750
2067
  }
2068
+ if (yearField.indexOf("-") !== -1) {
2069
+ return yearField.split("-").join(through(opts));
2070
+ }
1751
2071
  return yearField;
1752
2072
  }
1753
2073
  function stepYears(yearField, opts) {
@@ -1757,7 +2077,7 @@ function stepYears(yearField, opts) {
1757
2077
  if (interval <= 1) {
1758
2078
  return "every year";
1759
2079
  }
1760
- let phrase = "every " + getNumber(interval, opts) + " years";
2080
+ let phrase = interval === 2 ? "every other year" : "every " + getNumber(interval, opts) + " years";
1761
2081
  if (start !== "*" && start !== "0") {
1762
2082
  phrase += " from " + start;
1763
2083
  }