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.
@@ -670,6 +670,25 @@ function renderMinuteSpanInHour(
670
670
 
671
671
  // Seconds composed with the rest: "in den Sekunden 0 und 30 jeder Minute, um
672
672
  // 9:05 Uhr".
673
+ // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
674
+ // cadences that read as contradictory ("jede Sekunde, alle 2 Minuten"). Bind
675
+ // them in the genitive ("jede Sekunde jeder zweiten Minute"), mirroring
676
+ // English. Other strides, a restricted hour, and an hour cadence keep the
677
+ // juxtaposed form.
678
+ function isEveryOtherMinuteSeconds(
679
+ ir: IR,
680
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
681
+ ): boolean {
682
+ if (plan.rest.kind !== 'minuteFrequency' ||
683
+ ir.shapes.second !== 'wildcard' || ir.shapes.hour !== 'wildcard') {
684
+ return false;
685
+ }
686
+
687
+ const minuteStep = stepSegment(ir.analyses.segments.minute);
688
+
689
+ return minuteStep.startToken === '*' && minuteStep.interval === 2;
690
+ }
691
+
673
692
  function renderComposeSeconds(
674
693
  ir: IR,
675
694
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -682,7 +701,8 @@ function renderComposeSeconds(
682
701
  if ((plan.rest.kind === 'clockTimes' ||
683
702
  plan.rest.kind === 'compactClockTimes') &&
684
703
  ir.shapes.minute === 'single') {
685
- const cadence = hourCadence(ir, +ir.pattern.minute);
704
+ const minute = +ir.pattern.minute;
705
+ const cadence = hourCadence(ir, minute) ?? hourRangeCadence(ir, minute);
686
706
 
687
707
  if (cadence !== null) {
688
708
  return cadence;
@@ -699,21 +719,21 @@ function renderComposeSeconds(
699
719
  clockMinuteGenitive(plan.rest.times, opts.style.sep);
700
720
  }
701
721
 
702
- // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
703
- // cadences that read as contradictory ("jede Sekunde, alle 2 Minuten"). Bind
704
- // them in the genitive ("jede Sekunde jeder zweiten Minute"), mirroring
705
- // English. Other strides, a restricted hour, and an hour cadence keep the
706
- // juxtaposed form.
707
- if (plan.rest.kind === 'minuteFrequency' &&
708
- ir.shapes.second === 'wildcard' && ir.shapes.hour === 'wildcard') {
709
- const minuteStep = stepSegment(ir.analyses.segments.minute);
710
-
711
- if (minuteStep.startToken === '*' && minuteStep.interval === 2) {
712
- return secondsLead(ir) + ' jeder zweiten Minute';
713
- }
722
+ // A wildcard second under a minute */2 with a wildcard hour binds in the
723
+ // genitive ("jede Sekunde jeder zweiten Minute").
724
+ if (isEveryOtherMinuteSeconds(ir, plan)) {
725
+ return secondsLead(ir) + ' jeder zweiten Minute';
714
726
  }
715
727
 
716
- return secondsLead(ir) + ', ' + render(ir, plan.rest, opts);
728
+ // A compact clock-time rest folds a meaningful SINGLE second into its own
729
+ // leading clause, so the composer must not prepend a second lead that would
730
+ // double it. A wildcard or stepped second is not folded there (no
731
+ // clockSecond), so it still leads its own clause here.
732
+ const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
733
+ ir.analyses.clockSecond;
734
+ const lead = restOwnsLead ? '' : secondsLead(ir) + ', ';
735
+
736
+ return lead + render(ir, plan.rest, opts);
717
737
  }
718
738
 
719
739
  // True when a compose-seconds plan is a sub-minute second over a minute-0
@@ -752,18 +772,30 @@ function renderMinutesAcrossHours(
752
772
  opts: Opts
753
773
  ): string {
754
774
  const sep = opts.style.sep;
775
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence,
776
+ // not a wall of hour columns.
777
+ const cadence = unevenHourCadence(ir);
755
778
 
756
779
  // The wildcard form means every minute *during* each hour: render windows.
757
780
  if (plan.form === 'wildcard') {
758
- return 'jede Minute ' + duringHours(ir, plan.times, sep);
781
+ return cadence ?
782
+ 'jede Minute, ' + cadence :
783
+ 'jede Minute ' + duringHours(ir, plan.times, sep);
784
+ }
785
+
786
+ const minuteLead =
787
+ strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
788
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten');
789
+
790
+ if (cadence !== null) {
791
+ return minuteLead + ', ' + cadence;
759
792
  }
760
793
 
761
794
  const hours = plan.times.kind === 'fires' ?
762
795
  atHours(plan.times.fires) :
763
796
  joinList(hourSegmentParts(ir, 0, 0, sep));
764
797
 
765
- return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
766
- countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
798
+ return minuteLead + ', ' + hours;
767
799
  }
768
800
 
769
801
  // A minute clause across a stepped hour range. A wildcard minute (a cadence)
@@ -774,6 +806,12 @@ function renderMinuteSpanAcrossHourStep(
774
806
  ir: IR,
775
807
  plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>
776
808
  ): string {
809
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence; an
810
+ // offset-clean stride keeps its "in jeder N-ten Stunde" confinement.
811
+ const cadence = unevenHourCadence(ir);
812
+
813
+ // A wildcard minute over a stepped hour is reached only for a clean stride (a
814
+ // bounded or uneven step routes through minutesAcrossHours instead).
777
815
  if (plan.form === 'wildcard') {
778
816
  return 'jede Minute ' +
779
817
  everyNthHour(stepSegment(ir.analyses.segments.hour));
@@ -782,11 +820,11 @@ function renderMinuteSpanAcrossHourStep(
782
820
  // The minute (range or list) leads; the hour trails. A clean stride confines
783
821
  // to "in jeder N-ten Stunde" — the same cadence the wildcard form and the
784
822
  // minute-step compositions use, never a juxtaposed second frequency. A
785
- // bounded stride (reachable only via a range) enumerates its fires instead.
823
+ // bounded or uneven stride trails its endpoint-pinning cadence instead.
786
824
  const segment = stepSegment(ir.analyses.segments.hour);
787
- const hours = confinedHourStride(segment) ?
825
+ const hours = cadence ?? (confinedHourStride(segment) ?
788
826
  everyNthHour(segment) :
789
- atHours(segment.fires);
827
+ atHours(segment.fires));
790
828
 
791
829
  return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
792
830
  countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
@@ -805,10 +843,11 @@ function renderCompactClockTimes(
805
843
  const sep = opts.style.sep;
806
844
 
807
845
  if (plan.fold) {
808
- // An hour step (or arithmetic-progression hour list) under the single
809
- // pinned minute reads as a cadence, not a wall of clock times. (Returns
810
- // null for an irregular list or a range, which keep folding below.)
811
- const cadence = hourCadence(ir, plan.minute);
846
+ // An hour step or range (or arithmetic-progression hour list) under the
847
+ // single pinned minute reads as a cadence or window, not a wall of clock
848
+ // times. (Returns null for an irregular list, which keeps folding below.)
849
+ const cadence = hourCadence(ir, plan.minute) ??
850
+ hourRangeCadence(ir, plan.minute);
812
851
 
813
852
  if (cadence !== null) {
814
853
  return cadence;
@@ -821,11 +860,12 @@ function renderCompactClockTimes(
821
860
  joinList(hourSegmentParts(ir, plan.minute, ir.analyses.clockSecond, sep));
822
861
  }
823
862
 
824
- // A range among the hours reads as a window; otherwise a flat hour list.
825
- const hours = fieldSegments(ir, 'hour')
826
- .some((segment) => segment.kind === 'range') ?
827
- joinList(hourSegmentParts(ir, 0, 0, sep)) :
828
- atHours(hourFires(ir));
863
+ // A bounded or uneven hour stride reads as its endpoint-pinning cadence; else
864
+ // a range among the hours reads as a window, otherwise a flat hour list.
865
+ const hours = unevenHourCadence(ir) ??
866
+ (fieldSegments(ir, 'hour').some((segment) => segment.kind === 'range') ?
867
+ joinList(hourSegmentParts(ir, 0, 0, sep)) :
868
+ atHours(hourFires(ir)));
829
869
 
830
870
  // A folded second has no single clock time to attach to here, so it leads
831
871
  // as its own clause ("in Sekunde 30, ..."). It is the bare second (not
@@ -858,7 +898,14 @@ function renderMinuteFrequency(
858
898
  }
859
899
 
860
900
  if (plan.hours.kind === 'during') {
861
- return base + ' ' + duringHours(ir, plan.hours.times, sep);
901
+ // A bounded or uneven hour stride confines the minute cadence to its own
902
+ // endpoint-pinning hour cadence ("alle 15 Minuten, alle 5 Stunden von 0 bis
903
+ // 20 Uhr").
904
+ const cadence = unevenHourCadence(ir);
905
+
906
+ return cadence ?
907
+ base + ', ' + cadence :
908
+ base + ' ' + duringHours(ir, plan.hours.times, sep);
862
909
  }
863
910
 
864
911
  if (plan.hours.kind === 'step') {
@@ -871,10 +918,16 @@ function renderMinuteFrequency(
871
918
  return base;
872
919
  }
873
920
 
874
- // A stepped hour field as a phrase: the cadence when clean ("alle 6 Stunden"),
875
- // else its discrete fires when uneven or bounded ("um 0, 5, 10, 15 und 20
876
- // Uhr"). Shared by the bare hour step and the minute-step compositions.
921
+ // A stepped hour field as a phrase: an offset-clean stride is its bare or "ab"
922
+ // cadence; a bounded or uneven stride pins both ends ("alle 2 Stunden von 9
923
+ // bis 17 Uhr"). Shared by the bare hour step and the minute-step compositions.
877
924
  function hourStepPhrase(ir: IR): string {
925
+ const cadence = unevenHourCadence(ir);
926
+
927
+ if (cadence !== null) {
928
+ return cadence;
929
+ }
930
+
878
931
  const segment = stepSegment(ir.analyses.segments.hour);
879
932
 
880
933
  return cleanStep(segment, 24) ?
@@ -909,12 +962,56 @@ function hourStrideCadence(
909
962
  return cadence + ' von ' + start + ' bis ' + last + ' Uhr';
910
963
  }
911
964
 
965
+ // An hour list's arithmetic progression, or null when its values are not a step
966
+ // the renderer should speak as a cadence. The core rewrites a uneven hour step
967
+ // (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its literal
968
+ // fire list, indistinguishable in the IR from a hand-written list; the renderer
969
+ // recovers the cadence from the values. A progression starting at zero is a
970
+ // `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is only
971
+ // a step when it is too long to be a deliberate clock-time list (9,17 is two
972
+ // named times, not a cadence). Interval one is a plain range, never a step.
973
+ function hourListStride(
974
+ values: number[]
975
+ ): {start: number; interval: number; last: number} | null {
976
+ if (values.length < 2) {
977
+ return null;
978
+ }
979
+
980
+ const interval = values[1] - values[0];
981
+
982
+ if (interval < 2) {
983
+ return null;
984
+ }
985
+
986
+ for (let i = 2; i < values.length; i += 1) {
987
+ if (values[i] - values[i - 1] !== interval) {
988
+ return null;
989
+ }
990
+ }
991
+
992
+ if (values[0] !== 0 && values.length < 5) {
993
+ return null;
994
+ }
995
+
996
+ return {interval, last: values[values.length - 1], start: values[0]};
997
+ }
998
+
999
+ // Whether an hour stride wraps the day cleanly from within its first interval
1000
+ // (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
1001
+ // stride has no distinct endpoint and keeps its bare or "ab" cadence. Every
1002
+ // other stride — a uneven interval, or one starting at or past its interval (a
1003
+ // bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
1004
+ function offsetCleanStride(
1005
+ stride: {start: number; interval: number}
1006
+ ): boolean {
1007
+ return stride.start < stride.interval && 24 % stride.interval === 0;
1008
+ }
1009
+
912
1010
  // The hour field's stride, or null when the hour is not a cadence: a step
913
1011
  // segment yields its {start, interval, last} directly; an all-single hour list
914
- // yields one only when its values form a long-enough arithmetic progression
915
- // (so an irregular list like 9,17 keeps enumerating). The IR is unchanged —
916
- // the renderer recognizes the stride and speaks it as a cadence instead of the
917
- // clock-time cross-product.
1012
+ // yields one only when its values form a step progression (so an irregular list
1013
+ // like 9,17 keeps enumerating). The IR is unchanged — the renderer recognizes
1014
+ // the stride and speaks it as a cadence, not the clock-time cross-product.
918
1015
  function hourStride(
919
1016
  ir: IR
920
1017
  ): {start: number; interval: number; last: number} | null {
@@ -927,6 +1024,13 @@ function hourStride(
927
1024
 
928
1025
  if (segments.length === 1 && segments[0].kind === 'step') {
929
1026
  const segment = segments[0];
1027
+
1028
+ // A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
1029
+ // value, not a stride: it has no interval to speak and no endpoint to pin.
1030
+ if (segment.fires.length < 2) {
1031
+ return null;
1032
+ }
1033
+
930
1034
  const start = segment.startToken === '*' ?
931
1035
  0 :
932
1036
  +segment.startToken.split('-')[0];
@@ -936,9 +1040,25 @@ function hourStride(
936
1040
  }
937
1041
 
938
1042
  const values = singleValues(segments);
939
- const step = values && arithmeticStep(values);
940
1043
 
941
- return step || null;
1044
+ return values && hourListStride(values);
1045
+ }
1046
+
1047
+ // The bounded cadence for an hour stride that pins both clock-time endpoints,
1048
+ // or null when the hour is not such a stride. The core rewrites a uneven step
1049
+ // to its fire list, so a minute window/list/step crossed with it lands in the
1050
+ // enumerating list paths; there the bounded hour reads better as its cadence
1051
+ // ("…, alle 5 Stunden von 0 bis 20 Uhr") than as a wall of clock times. An
1052
+ // offset-clean stride keeps its existing confinement form, so only the
1053
+ // endpoint-bearing case routes here.
1054
+ function unevenHourCadence(ir: IR): string | null {
1055
+ const stride = hourStride(ir);
1056
+
1057
+ if (!stride || offsetCleanStride(stride)) {
1058
+ return null;
1059
+ }
1060
+
1061
+ return hourStrideCadence(stride);
942
1062
  }
943
1063
 
944
1064
  // The second's status against a pinned minute: a wildcard or sub-minute step
@@ -994,7 +1114,13 @@ function hourCadence(ir: IR, minute: number): string | null {
994
1114
 
995
1115
  const fires = (stride.last - stride.start) / stride.interval + 1;
996
1116
 
997
- if (ir.pattern.second === '0' && fires <= maxClockTimes) {
1117
+ // A short stride that spells out as few clock times stays an enumeration only
1118
+ // when it wraps cleanly (an offset-clean stride with no endpoint): the bare
1119
+ // or "ab" form is no shorter than the list. A bounded or uneven stride has no
1120
+ // clean wrap, so its endpoint-pinning cadence ("alle 5 Stunden von 0 bis 20
1121
+ // Uhr") reads better however short.
1122
+ if (ir.pattern.second === '0' && fires <= maxClockTimes &&
1123
+ offsetCleanStride(stride)) {
998
1124
  return null;
999
1125
  }
1000
1126
 
@@ -1012,15 +1138,68 @@ function hourCadence(ir: IR, minute: number): string | null {
1012
1138
  everyNthHour(segment);
1013
1139
  }
1014
1140
 
1141
+ // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1142
+ // lead clause to fold in, so the bounded cadence stands on its own ("alle 5
1143
+ // Stunden von 0 bis 20 Uhr").
1144
+ if (minute === 0 && ir.pattern.second === '0') {
1145
+ return hourStrideCadence(stride);
1146
+ }
1147
+
1015
1148
  return hourCadenceLead(ir, minute) + ', ' + hourStrideCadence(stride);
1016
1149
  }
1017
1150
 
1018
- // Whether an hour cadence applies to a plan with a single pinned minute — the
1019
- // signal that the clause is a cadence, not a daily clock-time list, so the
1020
- // "täglich" frame must not be added.
1151
+ // Whether an hour cadence or hour-range window applies to a plan with a single
1152
+ // pinned minute — the signal that the clause is a cadence/window, not a daily
1153
+ // clock-time list, so the "täglich" frame must not be added.
1021
1154
  function hourCadenceApplies(ir: IR): boolean {
1022
- return ir.shapes.minute === 'single' &&
1023
- hourCadence(ir, +ir.pattern.minute) !== null;
1155
+ if (ir.shapes.minute !== 'single') {
1156
+ return false;
1157
+ }
1158
+
1159
+ const minute = +ir.pattern.minute;
1160
+
1161
+ return hourCadence(ir, minute) !== null ||
1162
+ hourRangeCadence(ir, minute) !== null;
1163
+ }
1164
+
1165
+ // Whether the hour field is a range — or a list whose segments include a
1166
+ // range — and so forms a window rather than a cross-product of clock times.
1167
+ // A pure single-value list (9,17) has no range to span and still enumerates;
1168
+ // a step is handled by hourStride/hourCadence.
1169
+ function hasHourWindow(ir: IR): boolean {
1170
+ const segments = fieldSegments(ir, 'hour');
1171
+
1172
+ return !!segments && segments.some(function range(segment) {
1173
+ return segment.kind === 'range';
1174
+ });
1175
+ }
1176
+
1177
+ // Render an hour range (or a list whose segments include a range) under
1178
+ // minute 0 and a meaningful second as the hour-range window — the lead clause,
1179
+ // then "von 9 bis 17 Uhr" (and any non-contiguous hour as "und um 22 Uhr") —
1180
+ // instead of cross-multiplying the hours into a wall of clock times. The
1181
+ // hour-RANGE analog of hourCadence; returns the bare clause (the day frame is
1182
+ // suppressed by hourCadenceApplies). Returns null when the hour has no range,
1183
+ // when the minute is non-zero (a real clock minute the existing window form
1184
+ // already speaks), or when a plain :00 set carries no clause. Renderer-only;
1185
+ // the IR is unchanged.
1186
+ function hourRangeCadence(ir: IR, minute: number): string | null {
1187
+ if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
1188
+ return null;
1189
+ }
1190
+
1191
+ return hourCadenceLead(ir, minute) + ', ' + hourRangeWindowTail(ir);
1192
+ }
1193
+
1194
+ // The hour-range window as a cadence tail at the top of each hour: each range
1195
+ // segment is "von X bis Y Uhr", any non-contiguous hour is "um Z Uhr", joined
1196
+ // — the same parts the bare "stündlich von 9 bis 17 Uhr" window forms, minus
1197
+ // the "stündlich" prefix the lead replaces. The minute has folded into the
1198
+ // lead, so the parts close on the top of their final hour.
1199
+ function hourRangeWindowTail(ir: IR): string {
1200
+ // Minute 0 with a falsy second renders each part as a bare hour ("von 9 bis
1201
+ // 17 Uhr", "um 22 Uhr"); the separator is unused in that path.
1202
+ return joinList(hourSegmentParts(ir, 0, 0, ':'));
1024
1203
  }
1025
1204
 
1026
1205
  // An hourly window: "stündlich von 9 bis 17 Uhr", or every minute across it.
@@ -1043,9 +1222,15 @@ function renderHourRange(
1043
1222
  return 'stündlich ' + window;
1044
1223
  }
1045
1224
 
1046
- // A non-zero single minute ('lead') or a minute range leads the window.
1047
- return countedPhrase(ir, 'minute', 'Minute', 'Minuten') +
1048
- ' jeder Stunde, ' + window;
1225
+ // A non-zero single minute ('lead') or a minute range leads the window. A
1226
+ // non-uniform minute step the core enumerated to a fire list reads as its
1227
+ // bounded cadence ("alle 2 Minuten von Minute 3 bis 59 jeder Stunde") instead
1228
+ // of the wall of fires; an irregular list or a single minute keeps the
1229
+ // counted form.
1230
+ return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute,
1231
+ 'jeder Stunde') ??
1232
+ countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde') +
1233
+ ', ' + window;
1049
1234
  }
1050
1235
 
1051
1236
  // One or more clock times: "um 9 Uhr", "um 14:30 Uhr", "um 9 und 17 Uhr".
@@ -1054,10 +1239,12 @@ function renderClockTimes(
1054
1239
  plan: Extract<PlanNode, {kind: 'clockTimes'}>,
1055
1240
  opts: Opts
1056
1241
  ): string {
1057
- // An hour step (or arithmetic-progression hour list) under a single pinned
1058
- // minute reads as a cadence rather than a cross-product of clock times.
1242
+ // An hour step or range (or arithmetic-progression hour list) under a single
1243
+ // pinned minute reads as a cadence or window rather than a cross-product of
1244
+ // clock times.
1059
1245
  if (ir.shapes.minute === 'single') {
1060
- const cadence = hourCadence(ir, +ir.pattern.minute);
1246
+ const minute = +ir.pattern.minute;
1247
+ const cadence = hourCadence(ir, minute) ?? hourRangeCadence(ir, minute);
1061
1248
 
1062
1249
  if (cadence !== null) {
1063
1250
  return cadence;