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.
@@ -3,7 +3,9 @@
3
3
 
4
4
  import {pad} from '../../core/format.js';
5
5
  import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
6
- import {arithmeticStep, toFieldNumber} from '../../core/util.js';
6
+ import {
7
+ arithmeticStep, orderWeekdaysForDisplay, toFieldNumber
8
+ } from '../../core/util.js';
7
9
  import type {Cronli5Options} from '../../types.js';
8
10
  import type {
9
11
  Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
@@ -51,6 +53,12 @@ function everyN(interval: number, unit: Unit): string {
51
53
  return 'alle ' + interval + ' ' + unit.plural;
52
54
  }
53
55
 
56
+ // Append a scope anchor to a clause, separated by a space; an empty anchor
57
+ // (a context that names that field in its own clause) leaves the clause bare.
58
+ function withAnchor(clause: string, anchor: string): string {
59
+ return anchor ? clause + ' ' + anchor : clause;
60
+ }
61
+
54
62
  // The first segment of a step field, which the plan guarantees is step-kinded.
55
63
  function stepSegment(segments: Segment[] | null): StepSegment {
56
64
  return (segments as Segment[])[0] as StepSegment;
@@ -108,8 +116,9 @@ function stepClause(segment: StepSegment, unit: Unit, anchor: string): string {
108
116
  const short = start !== 0 && segment.fires.length <= 3;
109
117
 
110
118
  if (segment.startToken.indexOf('-') !== -1 || short) {
111
- return 'in den ' + unit.plural + ' ' + joinList(segment.fires.map(String)) +
112
- ' ' + anchor;
119
+ return withAnchor(
120
+ 'in den ' + unit.plural + ' ' + joinList(segment.fires.map(String)),
121
+ anchor);
113
122
  }
114
123
 
115
124
  return renderStride({
@@ -208,7 +217,9 @@ function weekdayRange(bounds: [string, string]): string {
208
217
 
209
218
  // "montags", "montags bis freitags", "montags, mittwochs und freitags".
210
219
  function weekdayQualifier(ir: IR): string {
211
- const segments = flattenSteps(fieldSegments(ir, 'weekday'));
220
+ // Weekday lists display Monday-first (Sunday last); a lone range keeps its
221
+ // form. The IR stays canonical (Sunday=0). The helper flattens steps.
222
+ const segments = orderWeekdaysForDisplay(fieldSegments(ir, 'weekday'));
212
223
 
213
224
  if (segments.length === 1 && segments[0].kind === 'range') {
214
225
  return weekdayRange(segments[0].bounds);
@@ -460,10 +471,21 @@ function countedPhrase(
460
471
  return 'in den ' + plural + ' ' + joinList(fieldValues(ir, field));
461
472
  }
462
473
 
463
- // The seconds clause: "alle 30 Sekunden" for a step, else "in Sekunde 15
464
- // jeder Minute".
474
+ // The minute scope for a seconds clause: "jeder Minute" only when the minute
475
+ // is a wildcard (the seconds really do fire in every minute). A restricted
476
+ // minute (single/list/range/step) is named by its own clause, so the seconds
477
+ // clause drops the scope — "jeder Minute" would otherwise contradict the fixed
478
+ // minute ("in Sekunde 30 jeder Minute, in Minute 30" fires at second 30 of
479
+ // minute 30, not every minute).
480
+ function minuteAnchor(ir: IR): string {
481
+ return ir.pattern.minute === '*' ? 'jeder Minute' : '';
482
+ }
483
+
484
+ // The seconds clause: "alle 30 Sekunden" for a step, "in Sekunde 15 jeder
485
+ // Minute" under a wildcard minute, else the bare "in Sekunde 15" when the
486
+ // minute is fixed (its own clause names it).
465
487
  function secondsLead(ir: IR): string {
466
- return secondsClause(ir, 'jeder Minute');
488
+ return secondsClause(ir, minuteAnchor(ir));
467
489
  }
468
490
 
469
491
  // The second clause counted against an arbitrary anchor. The anchor is "jeder
@@ -486,7 +508,7 @@ function secondsClause(ir: IR, anchor: string): string {
486
508
  }
487
509
 
488
510
  return strideFromSegments(segments as Segment[], UNITS.second, anchor) ??
489
- countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ' ' + anchor;
511
+ withAnchor(countedPhrase(ir, 'second', 'Sekunde', 'Sekunden'), anchor);
490
512
  }
491
513
 
492
514
  // A clock time that always shows its minutes: "9:00", "9:30".
@@ -888,15 +910,22 @@ function renderMinuteFrequency(
888
910
  const segment = stepSegment(ir.analyses.segments.minute);
889
911
  const sep = opts.style.sep;
890
912
  const clean = cleanStep(segment, 60);
891
- const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
892
913
 
893
914
  if (plan.hours.kind === 'window') {
915
+ // A single fixed hour (from === to) drops the "jeder Stunde" tail — the
916
+ // window names that one hour, so "jeder Stunde" (every hour) contradicts
917
+ // it. A range keeps it: the cadence truly repeats across each hour.
918
+ const singleHour = plan.hours.from === plan.hours.to;
919
+ const base = stepClause(segment, UNITS.minute,
920
+ singleHour ? '' : 'jeder Stunde');
894
921
  const window = hourWindow(plan.hours.from, plan.hours.to, plan.hours.last,
895
922
  sep);
896
923
 
897
924
  return clean ? base + ' ' + window : base + ', ' + window;
898
925
  }
899
926
 
927
+ const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
928
+
900
929
  if (plan.hours.kind === 'during') {
901
930
  // A bounded or uneven hour stride confines the minute cadence to its own
902
931
  // endpoint-pinning hour cadence ("alle 15 Minuten, alle 5 Stunden von 0 bis
@@ -918,9 +947,13 @@ function renderMinuteFrequency(
918
947
  return base;
919
948
  }
920
949
 
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
950
+ // A stepped hour field as a phrase: a clean stride from midnight is the bare
951
+ // cadence ("alle 2 Stunden"); an open offset-clean stride names only its start
952
+ // ("alle 2 Stunden ab 1 Uhr") since it wraps the day with no distinct
953
+ // endpoint; a bounded or uneven stride pins both ends ("alle 2 Stunden von 9
923
954
  // bis 17 Uhr"). Shared by the bare hour step and the minute-step compositions.
955
+ // An explicitly bounded step (`a-b/n`) keeps its enumerated hours, matching
956
+ // en/fi/zh; only an OPEN step (`m/n`) reads as the wrapping cadence.
924
957
  function hourStepPhrase(ir: IR): string {
925
958
  const cadence = unevenHourCadence(ir);
926
959
 
@@ -930,9 +963,34 @@ function hourStepPhrase(ir: IR): string {
930
963
 
931
964
  const segment = stepSegment(ir.analyses.segments.hour);
932
965
 
933
- return cleanStep(segment, 24) ?
934
- everyN(segment.interval, UNITS.hour) :
935
- atHours(segment.fires);
966
+ if (cleanStep(segment, 24)) {
967
+ return everyN(segment.interval, UNITS.hour);
968
+ }
969
+
970
+ // An open offset-clean step (`m/n`, m < n dividing 24) wraps the day with no
971
+ // endpoint: name only its start, the cadence en/fi/zh and the compose paths
972
+ // already speak — never the enumerated hour list. A bounded `a-b/n` keeps its
973
+ // explicit hours.
974
+ const stride = openOffsetCleanStride(ir, segment);
975
+
976
+ return stride ? hourStrideCadence(stride) : atHours(segment.fires);
977
+ }
978
+
979
+ // The stride of an OPEN offset-clean hour step (`m/n`, m < n dividing 24),
980
+ // or null for any other step: such a step wraps the day with no endpoint and
981
+ // reads as the "alle N Stunden ab M Uhr" cadence. An explicitly bounded step
982
+ // (`a-b/n`, startToken carries a `-`) is excluded so it keeps its enumerated
983
+ // hours, matching en/fi/zh.
984
+ function openOffsetCleanStride(
985
+ ir: IR, segment: StepSegment
986
+ ): {start: number; interval: number; last: number} | null {
987
+ if (segment.startToken.indexOf('-') !== -1) {
988
+ return null;
989
+ }
990
+
991
+ const stride = hourStride(ir);
992
+
993
+ return stride && offsetCleanStride(stride) ? stride : null;
936
994
  }
937
995
 
938
996
  // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
@@ -1078,7 +1136,7 @@ function subMinuteSecond(ir: IR): boolean {
1078
1136
  function hourCadenceLead(ir: IR, minute: number): string {
1079
1137
  if (minute === 0) {
1080
1138
  if (subMinuteSecond(ir)) {
1081
- return secondsClause(ir, 'jeder Minute') + ' für eine Minute';
1139
+ return withAnchor(secondsClause(ir, minuteAnchor(ir)), 'für eine Minute');
1082
1140
  }
1083
1141
 
1084
1142
  return secondsClause(ir, 'jeder Stunde');
@@ -1092,7 +1150,7 @@ function hourCadenceLead(ir: IR, minute: number): string {
1092
1150
  return minutePhrase;
1093
1151
  }
1094
1152
 
1095
- return secondsClause(ir, 'jeder Minute') + ', ' + minutePhrase;
1153
+ return secondsClause(ir, minuteAnchor(ir)) + ', ' + minutePhrase;
1096
1154
  }
1097
1155
 
1098
1156
  // Render an hour step (or arithmetic-progression hour list) under a single
@@ -1134,8 +1192,8 @@ function hourCadence(ir: IR, minute: number): string | null {
1134
1192
  confinedHourStride(segment);
1135
1193
 
1136
1194
  if (confined) {
1137
- return secondsClause(ir, 'jeder Minute') + ' für eine Minute ' +
1138
- everyNthHour(segment);
1195
+ return withAnchor(secondsClause(ir, minuteAnchor(ir)), 'für eine Minute') +
1196
+ ' ' + everyNthHour(segment);
1139
1197
  }
1140
1198
 
1141
1199
  // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
@@ -1208,11 +1266,14 @@ function renderHourRange(
1208
1266
  plan: Extract<PlanNode, {kind: 'hourRange'}>,
1209
1267
  opts: Opts
1210
1268
  ): string {
1211
- // A bare close (`boundMinute` null) lands on the top of the final hour
1212
- // (minute 0), matching the minute-0 baseline, with the minutes stated
1213
- // separately; a single fire or wildcard names an exact closing minute.
1214
- const window = hourWindow(plan.from, plan.to, plan.boundMinute ?? 0,
1215
- opts.style.sep);
1269
+ // The close lands on the top of the final hour (minute 0) unless the minute
1270
+ // genuinely runs to the end of that hour i.e. a wildcard minute, which
1271
+ // fills every minute and states no separate clause. A pinned/listed/ranged
1272
+ // minute is named in its own lead clause, so folding it into the close too
1273
+ // would read as a span ("bis 17:05 Uhr") that contradicts the minute clause;
1274
+ // the window stays bare ("bis 17 Uhr").
1275
+ const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
1276
+ const window = hourWindow(plan.from, plan.to, last, opts.style.sep);
1216
1277
 
1217
1278
  if (plan.minuteForm === 'wildcard') {
1218
1279
  return 'jede Minute ' + window;
@@ -1343,8 +1404,17 @@ function needsDailyFrame(ir: IR): boolean {
1343
1404
  return true;
1344
1405
  }
1345
1406
 
1346
- return ir.plan.kind === 'hourStep' &&
1347
- !cleanStep(stepSegment(ir.analyses.segments.hour), 24);
1407
+ if (ir.plan.kind !== 'hourStep') {
1408
+ return false;
1409
+ }
1410
+
1411
+ // An hour step rendered as a cadence ("alle N Stunden [ab M Uhr]") is a
1412
+ // frequency, not a daily clock-time list, so it takes no "täglich" frame —
1413
+ // only a bounded `a-b/n` step that enumerates its hours ("um 1, 3, … Uhr")
1414
+ // needs the recurring frame.
1415
+ const segment = stepSegment(ir.analyses.segments.hour);
1416
+
1417
+ return !cleanStep(segment, 24) && !openOffsetCleanStride(ir, segment);
1348
1418
  }
1349
1419
 
1350
1420
  function render(ir: IR, plan: PlanNode, opts: Opts): string {
@@ -34,7 +34,8 @@ const dialects: {[name: string]: DialectStyle} = {
34
34
  pm: 'p.m.',
35
35
  sep: ':',
36
36
  serialComma: true,
37
- through: ' through '
37
+ through: ' through ',
38
+ untilWindow: true
38
39
  },
39
40
  house: {
40
41
  am: 'AM',
@@ -58,7 +59,10 @@ function resolveDialect(
58
59
  dialect?: Cronli5Options['dialect']
59
60
  ): DialectStyle {
60
61
  if (typeof dialect === 'object' && dialect !== null) {
61
- return {...dialects.us, ...dialect};
62
+ // A custom style inherits the US base but NOT the until-window: a custom
63
+ // dialect that only overrides the connective (e.g. `{through: ' until '}`)
64
+ // keeps the "through <last fire>" close, just spelled with its own word.
65
+ return {...dialects.us, untilWindow: false, ...dialect};
62
66
  }
63
67
 
64
68
  // The legacy 'uk' name resolves to 'gb'; a name another language owns