cronli5 0.1.5 → 0.1.6

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.
@@ -259,9 +259,24 @@ function composeHourCadence(
259
259
  const clockRest = plan.rest.kind === 'clockTimes' ||
260
260
  plan.rest.kind === 'compactClockTimes';
261
261
 
262
- return clockRest && ir.shapes.minute === 'single' ?
263
- hourCadence(ir, +ir.pattern.minute, opts) :
264
- null;
262
+ if (!clockRest || ir.shapes.minute !== 'single') {
263
+ return null;
264
+ }
265
+
266
+ const minute = +ir.pattern.minute;
267
+
268
+ return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
269
+ }
270
+
271
+ // A wildcard or stepped second with a fixed minute across one or more specific
272
+ // hours: the seconds confine to the clock time(s), each minute named.
273
+ function isPinnedMinuteSeconds(
274
+ ir: IR,
275
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
276
+ ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
277
+ {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
278
+ return plan.rest.kind === 'clockTimes' &&
279
+ (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step');
265
280
  }
266
281
 
267
282
  function renderComposeSeconds(
@@ -281,8 +296,7 @@ function renderComposeSeconds(
281
296
 
282
297
  // A wildcard or stepped second with the minute pinned to a single value
283
298
  // across one or more specific hours: the seconds confine to the clock time.
284
- if (plan.rest.kind === 'clockTimes' &&
285
- (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
299
+ if (isPinnedMinuteSeconds(ir, plan)) {
286
300
  return pinnedMinuteSeconds(ir, plan.rest, opts);
287
301
  }
288
302
 
@@ -317,7 +331,15 @@ function renderComposeSeconds(
317
331
  return secondsLeadClause(ir, opts) + ' de ' + render(ir, plan.rest, opts);
318
332
  }
319
333
 
320
- return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
334
+ // A compact clock-time rest folds a meaningful SINGLE second into its own
335
+ // leading clause, so the composer must not prepend a second lead that would
336
+ // double it. A wildcard or stepped second is not folded there (no
337
+ // clockSecond), so it still leads its own clause here.
338
+ const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
339
+ ir.analyses.clockSecond;
340
+ const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
341
+
342
+ return lead + render(ir, plan.rest, opts);
321
343
  }
322
344
 
323
345
  // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
@@ -354,7 +376,12 @@ function pinnedMinuteSeconds(
354
376
  const dayTrail = leadingQualifier(ir, opts).trimEnd();
355
377
  const trail = dayTrail ? ', ' + dayTrail : '';
356
378
 
357
- if (+rest.times[0].minute === 0) {
379
+ // The "durante un minuto a las 9" duration form drops the clock minute, so it
380
+ // is correct only when the minute is a SINGLE 0 — every clock time at :00. A
381
+ // minute LIST whose first value is 0 (e.g. */45 → :00, :45) must name each
382
+ // minute, never collapse to the bare hour (which once repeated it, "a las 9 y
383
+ // 9"), so it takes the explicit clock list.
384
+ if (+rest.times[0].minute === 0 && ir.shapes.minute === 'single') {
358
385
  return secondsLeadClause(ir, opts) + ' durante un minuto ' +
359
386
  durationHourList(rest.times, opts) + trail;
360
387
  }
@@ -555,11 +582,20 @@ function renderMinuteFrequency(
555
582
  'hora', opts);
556
583
 
557
584
  if (plan.hours.kind === 'during') {
558
- // An offset step (e.g. 1/2) arrives here; a single step reads as a
559
- // confinement, not the verbose window list.
560
- phrase += singleHourStep(ir.analyses.segments.hour) ?
561
- ', ' + stepHourSpan(stepSegment(ir.analyses.segments.hour), opts) :
562
- ' ' + hourSpanFromTimes(ir, plan.hours.times, opts);
585
+ // A uneven hour stride confines the minute cadence to its own bounded hour
586
+ // cadence ("cada 15 minutos, cada cinco horas de las 00:00 a las 20:00").
587
+ const cadence = unevenHourCadence(ir, opts);
588
+
589
+ if (cadence) {
590
+ phrase += ', ' + cadence;
591
+ }
592
+ else {
593
+ // An offset step (e.g. 1/2) arrives here; a single step reads as a
594
+ // confinement, not the verbose window list.
595
+ phrase += singleHourStep(ir.analyses.segments.hour) ?
596
+ ', ' + stepHourSpan(stepSegment(ir.analyses.segments.hour), opts) :
597
+ ' ' + hourSpanFromTimes(ir, plan.hours.times, opts);
598
+ }
563
599
  }
564
600
  else if (plan.hours.kind === 'window') {
565
601
  phrase += ' ' + hourWindow(plan.hours, opts);
@@ -602,7 +638,15 @@ function renderMinutesAcrossHours(
602
638
  plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
603
639
  opts: Opts
604
640
  ): string {
641
+ // A uneven hour stride reads as a cadence, not a wall of hour columns: the
642
+ // minute lead, then "cada N horas de las X a las Y".
643
+ const cadence = unevenHourCadence(ir, opts);
644
+
605
645
  if (plan.form === 'wildcard') {
646
+ if (cadence !== null) {
647
+ return 'cada minuto, ' + cadence + trailingQualifier(ir, opts);
648
+ }
649
+
606
650
  if (singleHourStep(ir.analyses.segments.hour)) {
607
651
  return 'cada minuto, ' +
608
652
  stepHourSpan(stepSegment(ir.analyses.segments.hour), opts) +
@@ -617,6 +661,10 @@ function renderMinutesAcrossHours(
617
661
  minuteRangeLead(ir.pattern.minute) :
618
662
  minutesList(ir, opts);
619
663
 
664
+ if (cadence !== null) {
665
+ return lead + ', ' + cadence + trailingQualifier(ir, opts);
666
+ }
667
+
620
668
  return lead + ', ' + atHourTimes(ir, plan.times, opts) +
621
669
  trailingQualifier(ir, opts);
622
670
  }
@@ -627,9 +675,12 @@ function renderMinuteSpanAcrossHourStep(
627
675
  opts: Opts
628
676
  ): string {
629
677
  const segment = stepSegment(ir.analyses.segments.hour);
678
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence; an
679
+ // offset-clean step keeps its confinement / per-step phrasing.
680
+ const cadence = unevenHourCadence(ir, opts);
630
681
 
631
- // A wildcard minute (a cadence) is reached only for a clean stride and is
632
- // confined; a plain range is a per-hour window keyed to the step.
682
+ // A wildcard minute (a cadence) is reached only for a clean stride (a bounded
683
+ // or uneven step routes through minutesAcrossHours instead) and is confined.
633
684
  if (plan.form === 'wildcard') {
634
685
  return 'cada minuto, ' + stepHourSpan(segment, opts) +
635
686
  trailingQualifier(ir, opts);
@@ -642,7 +693,8 @@ function renderMinuteSpanAcrossHourStep(
642
693
  minutesList(ir, opts) :
643
694
  minuteRangeLead(ir.pattern.minute);
644
695
 
645
- return lead + ', ' + stepHours(segment, opts) + trailingQualifier(ir, opts);
696
+ return lead + ', ' +
697
+ (cadence ?? stepHours(segment, opts)) + trailingQualifier(ir, opts);
646
698
  }
647
699
 
648
700
  // --- Hour renderers. ---
@@ -689,6 +741,15 @@ function renderHourStep(
689
741
  plan: Extract<PlanNode, {kind: 'hourStep'}>,
690
742
  opts: Opts
691
743
  ): string {
744
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence ("cada
745
+ // dos horas de las 09:00 a las 17:00"); an offset-clean step keeps its bare
746
+ // or "a partir de" cadence.
747
+ const cadence = unevenHourCadence(ir, opts);
748
+
749
+ if (cadence !== null) {
750
+ return cadence + trailingQualifier(ir, opts);
751
+ }
752
+
692
753
  return stepHours(stepSegment(ir.analyses.segments.hour), opts) +
693
754
  trailingQualifier(ir, opts);
694
755
  }
@@ -817,10 +878,13 @@ function renderClockTimes(
817
878
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
818
879
  opts: Opts
819
880
  ): string {
820
- // An hour step (or arithmetic-progression hour list) under a single pinned
821
- // minute reads as a cadence rather than a cross-product of clock times.
881
+ // An hour step or range (or arithmetic-progression hour list) under a single
882
+ // pinned minute reads as a cadence or window rather than a cross-product of
883
+ // clock times.
822
884
  if (ir.shapes.minute === 'single') {
823
- const cadence = hourCadence(ir, +ir.pattern.minute, opts);
885
+ const minute = +ir.pattern.minute;
886
+ const cadence = hourCadence(ir, minute, opts) ??
887
+ hourRangeCadence(ir, minute, opts);
824
888
 
825
889
  if (cadence !== null) {
826
890
  return cadence;
@@ -1162,10 +1226,11 @@ function renderCompactClockTimes(
1162
1226
  opts: Opts
1163
1227
  ): string {
1164
1228
  if (plan.fold) {
1165
- // An hour step (or arithmetic-progression hour list) under the single
1166
- // pinned minute reads as a cadence, not a wall of clock times. (Returns
1167
- // null for an irregular list or a range, which keep folding below.)
1168
- const cadence = hourCadence(ir, plan.minute, opts);
1229
+ // An hour step or range (or arithmetic-progression hour list) under the
1230
+ // single pinned minute reads as a cadence or window, not a wall of clock
1231
+ // times. (Returns null for an irregular list, which keeps folding below.)
1232
+ const cadence = hourCadence(ir, plan.minute, opts) ??
1233
+ hourRangeCadence(ir, plan.minute, opts);
1169
1234
 
1170
1235
  if (cadence !== null) {
1171
1236
  return cadence;
@@ -1187,8 +1252,13 @@ function renderCompactClockTimes(
1187
1252
  hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts);
1188
1253
  }
1189
1254
 
1190
- const phrase = minutesList(ir, opts) + ', ' +
1191
- hourSegmentTimes(ir, 0, null, opts) + trailingQualifier(ir, opts);
1255
+ // A uneven hour stride reads as a cadence after the minute lead, not a wall
1256
+ // of clock-time columns.
1257
+ const cadence = unevenHourCadence(ir, opts);
1258
+ const phrase = cadence ?
1259
+ minutesList(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts) :
1260
+ minutesList(ir, opts) + ', ' +
1261
+ hourSegmentTimes(ir, 0, null, opts) + trailingQualifier(ir, opts);
1192
1262
 
1193
1263
  return ir.analyses.clockSecond ?
1194
1264
  secondsLeadClause(ir, opts) + ', ' + phrase :
@@ -1373,12 +1443,75 @@ function hourStrideCadence(
1373
1443
  timePhrase(last, 0, null, opts);
1374
1444
  }
1375
1445
 
1446
+ // Whether an hour stride wraps the day cleanly from within its first interval
1447
+ // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
1448
+ // stride has no distinct endpoint and keeps its bare or "a partir de" cadence.
1449
+ // Every other stride — a uneven interval, or one starting at or past its
1450
+ // interval (a bounded `a-b/n`) — is a bounded set the cadence pins the ends of.
1451
+ function offsetCleanStride(
1452
+ stride: {start: number; interval: number}
1453
+ ): boolean {
1454
+ return stride.start < stride.interval && 24 % stride.interval === 0;
1455
+ }
1456
+
1457
+ // The bounded cadence for an hour stride that pins both clock-time endpoints,
1458
+ // or null when the hour is not such a stride. The core rewrites a uneven step
1459
+ // to its fire list, so a minute window/list/step crossed with it lands in the
1460
+ // enumerating list paths; there the bounded hour reads better as its cadence
1461
+ // ("…, cada cinco horas de las 00:00 a las 20:00") than as a wall of clock
1462
+ // times. An offset-clean stride keeps its existing confinement form, so only
1463
+ // the endpoint-bearing case routes here.
1464
+ function unevenHourCadence(ir: IR, opts: Opts): string | null {
1465
+ const stride = hourStride(ir);
1466
+
1467
+ if (!stride || offsetCleanStride(stride)) {
1468
+ return null;
1469
+ }
1470
+
1471
+ return hourStrideCadence(stride, opts);
1472
+ }
1473
+
1474
+ // An hour list's arithmetic progression, or null when its values are not a
1475
+ // step the renderer should speak as a cadence. The core rewrites a uneven hour
1476
+ // step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
1477
+ // literal fire list, indistinguishable in the IR from a hand-written list; the
1478
+ // renderer recovers the cadence from the values. A progression starting at zero
1479
+ // is a `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is
1480
+ // only a step when it is too long to be a deliberate clock-time list (e.g. 9,17
1481
+ // is two named times, not a cadence). Interval one is a plain range, never a
1482
+ // step.
1483
+ function hourListStride(
1484
+ values: number[]
1485
+ ): {start: number; interval: number; last: number} | null {
1486
+ if (values.length < 2) {
1487
+ return null;
1488
+ }
1489
+
1490
+ const interval = values[1] - values[0];
1491
+
1492
+ if (interval < 2) {
1493
+ return null;
1494
+ }
1495
+
1496
+ for (let i = 2; i < values.length; i += 1) {
1497
+ if (values[i] - values[i - 1] !== interval) {
1498
+ return null;
1499
+ }
1500
+ }
1501
+
1502
+ if (values[0] !== 0 && values.length < 5) {
1503
+ return null;
1504
+ }
1505
+
1506
+ return {interval, last: values[values.length - 1], start: values[0]};
1507
+ }
1508
+
1376
1509
  // The hour field's stride, or null when the hour is not a cadence: a step
1377
1510
  // segment yields its {start, interval, last} directly; an all-single hour
1378
- // list yields one only when its values form a long-enough arithmetic
1379
- // progression (so an irregular list like 9,17 keeps enumerating). The IR is
1380
- // unchanged — the renderer recognizes the stride and speaks it as a cadence
1381
- // instead of the clock-time cross-product.
1511
+ // list yields one only when its values form a step progression (so an irregular
1512
+ // list like 9,17 keeps enumerating). The IR is unchanged — the renderer
1513
+ // recognizes the stride and speaks it as a cadence instead of the clock-time
1514
+ // cross-product.
1382
1515
  function hourStride(
1383
1516
  ir: IR
1384
1517
  ): {start: number; interval: number; last: number} | null {
@@ -1386,6 +1519,13 @@ function hourStride(
1386
1519
 
1387
1520
  if (segments.length === 1 && segments[0].kind === 'step') {
1388
1521
  const segment = segments[0];
1522
+
1523
+ // A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
1524
+ // value, not a stride: it has no interval to speak and no endpoint to pin.
1525
+ if (segment.fires.length < 2) {
1526
+ return null;
1527
+ }
1528
+
1389
1529
  const start = segment.startToken === '*' ?
1390
1530
  0 :
1391
1531
  +segment.startToken.split('-')[0];
@@ -1395,9 +1535,8 @@ function hourStride(
1395
1535
  }
1396
1536
 
1397
1537
  const values = singleValues(segments);
1398
- const step = values && arithmeticStep(values);
1399
1538
 
1400
- return step || null;
1539
+ return values && hourListStride(values);
1401
1540
  }
1402
1541
 
1403
1542
  // The second's status against a pinned minute: a wildcard or sub-minute step
@@ -1452,7 +1591,13 @@ function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1452
1591
 
1453
1592
  const fires = (stride.last - stride.start) / stride.interval + 1;
1454
1593
 
1455
- if (ir.pattern.second === '0' && fires <= maxClockTimes) {
1594
+ // A short stride that spells out as few clock times stays an enumeration only
1595
+ // when it wraps cleanly (an offset-clean stride with no endpoint): the bare
1596
+ // or "a partir de" form is no shorter than the list. A bounded or uneven
1597
+ // stride has no clean wrap, so its endpoint-pinning cadence ("cada cinco
1598
+ // horas de las 00:00 a las 20:00") reads better however short.
1599
+ if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1600
+ offsetCleanStride(stride)) {
1456
1601
  return null;
1457
1602
  }
1458
1603
 
@@ -1468,6 +1613,13 @@ function hourCadence(ir: IR, minute: number, opts: Opts): string | null {
1468
1613
  stepHourSpan(confinement, opts) + trailingQualifier(ir, opts);
1469
1614
  }
1470
1615
 
1616
+ // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1617
+ // lead clause to fold in, so the bounded cadence stands on its own ("cada
1618
+ // cinco horas de las 00:00 a las 20:00").
1619
+ if (minute === 0 && ir.pattern.second === '0') {
1620
+ return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1621
+ }
1622
+
1471
1623
  return hourCadenceLead(ir, minute, opts) + ', ' +
1472
1624
  hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
1473
1625
  }
@@ -1488,6 +1640,45 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
1488
1640
  return segment;
1489
1641
  }
1490
1642
 
1643
+ // Whether the hour field is a range — or a list whose segments include a
1644
+ // range — and so forms a window rather than a cross-product of clock times.
1645
+ // A pure single-value list (9,17) has no range to span and still enumerates;
1646
+ // a step is handled by hourStride/hourCadence.
1647
+ function hasHourWindow(ir: IR): boolean {
1648
+ return hourSegments(ir).some(function range(segment) {
1649
+ return segment.kind === 'range';
1650
+ });
1651
+ }
1652
+
1653
+ // Render an hour range (or a list whose segments include a range) under
1654
+ // minute 0 and a meaningful second as the hour-range window — the lead clause,
1655
+ // then "de las 09:00 a las 17:00" (and any non-contiguous hour joined with
1656
+ // "y también") — instead of cross-multiplying the hours into a wall of clock
1657
+ // times. The hour-RANGE analog of hourCadence. Returns null when the hour has
1658
+ // no range, when the minute is non-zero (a real clock minute the existing
1659
+ // window form already speaks), or when a plain :00 set carries no clause.
1660
+ // Renderer-only; the IR is unchanged.
1661
+ function hourRangeCadence(ir: IR, minute: number, opts: Opts): string | null {
1662
+ if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
1663
+ return null;
1664
+ }
1665
+
1666
+ // A wildcard or sub-minute step second confined to minute 0 is the whole
1667
+ // minute-0 window ("durante un minuto"), confined to the hour range with the
1668
+ // "durante las horas …" idiom — kept distinct from the bare minute-0 window
1669
+ // ("cada hora de las 09:00 a las 17:00") so the confinement is never heard
1670
+ // as it — the hour-range analog of "durante un minuto, durante las horas
1671
+ // pares".
1672
+ if (subMinuteSecond(ir)) {
1673
+ return secondsClause(ir, 'minuto', opts) + ' durante un minuto, ' +
1674
+ 'durante las horas ' + hourSegmentTimes(ir, 0, null, opts) +
1675
+ trailingQualifier(ir, opts);
1676
+ }
1677
+
1678
+ return hourCadenceLead(ir, minute, opts) + ', ' +
1679
+ hourSegmentTimes(ir, 0, null, opts) + trailingQualifier(ir, opts);
1680
+ }
1681
+
1491
1682
  // --- Hour-time phrasing. ---
1492
1683
 
1493
1684
  // "a las 9:00" / "a la 1:00" / "al mediodía" for each fire hour.