cronli5 0.1.2 → 0.1.4

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/lang/zh.js CHANGED
@@ -175,7 +175,8 @@ function renderMinuteFrequency(ir, plan) {
175
175
  const base = minuteStep.startToken === "*" ? cadence(minuteStep.interval, UNITS.minute) : renderMinutePast(ir);
176
176
  const { hours } = plan;
177
177
  if (hours.kind === "step") {
178
- return cadence(stepSegment(ir, "hour").interval, UNITS.hour) + base;
178
+ const hourStep = stepSegment(ir, "hour");
179
+ return hourStep.startToken === "*" ? cadence(hourStep.interval, UNITS.hour) + base : "\u5728" + hourList(ir) + "\uFF0C" + base;
179
180
  }
180
181
  if (hours.kind === "single" || hours.kind === "window" && hours.from === hours.to) {
181
182
  return "\u5728" + hourWord(hours.from) + "\u81F3" + hours.from + "\u70B9" + hours.last + "\u5206\u4E4B\u95F4\uFF0C" + base;
@@ -190,6 +191,9 @@ function renderMinuteFrequency(ir, plan) {
190
191
  }
191
192
  function renderMinuteSpanInHour(ir, plan) {
192
193
  const span = plan;
194
+ if (ir.pattern.minute === "*") {
195
+ return hourWord(span.hour) + "\u7684\u6BCF\u4E00\u5206\u949F";
196
+ }
193
197
  return "\u5728" + hourWord(span.hour) + "\u81F3" + span.hour + "\u70B9" + span.span[1] + "\u5206\u4E4B\u95F4\uFF0C\u6BCF\u5206\u949F";
194
198
  }
195
199
  function renderMinutesAcrossHours(ir, plan) {
@@ -200,12 +204,17 @@ function renderMinutesAcrossHours(ir, plan) {
200
204
  return hourList(ir) + "\uFF0C\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
201
205
  }
202
206
  function renderMinuteSpanAcrossHourStep(ir, plan) {
203
- const cad = cadence(stepSegment(ir, "hour").interval, UNITS.hour);
207
+ const hourStep = stepSegment(ir, "hour");
204
208
  const { form } = plan;
205
- if (form === "wildcard") {
206
- return cad + "\u5185\uFF0C\u6BCF\u5206\u949F";
209
+ const minuteTail = form === "wildcard" ? "\u6BCF\u5206\u949F" : "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
210
+ if (hourStep.startToken !== "*") {
211
+ return form === "wildcard" ? "\u5728" + hourList(ir) + "\uFF0C" + minuteTail : hourList(ir) + "\uFF0C" + minuteTail;
207
212
  }
208
- return cad + "\uFF0C\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
213
+ if (hourStep.interval === 2 && form === "wildcard") {
214
+ return "\u5728\u5076\u6570\u5C0F\u65F6\uFF0C" + minuteTail;
215
+ }
216
+ const cad = cadence(hourStep.interval, UNITS.hour);
217
+ return form === "wildcard" ? cad + "\u5185\uFF0C" + minuteTail : cad + "\uFF0C" + minuteTail;
209
218
  }
210
219
  function renderClockTimes(ir, plan, opts) {
211
220
  const { times } = plan;
@@ -291,9 +300,19 @@ function isHourCadence(ir) {
291
300
  function composeSecondsOnHour(ir, plan, opts) {
292
301
  const sec = secondClause(ir);
293
302
  const { rest } = plan;
303
+ if ((rest.kind === "clockTimes" || rest.kind === "compactClockTimes") && ir.pattern.minute === "0") {
304
+ const clocks = hourFires(ir).map(function clock(hour) {
305
+ return hourWord(hour) + "0\u5206";
306
+ });
307
+ const tail = sec === "\u6BCF\u79D2" ? "\u7684\u6BCF\u4E00\u79D2" : "\u7684" + sec;
308
+ const core = joinAnd(clocks) + tail;
309
+ return isDaily(ir) ? "\u6BCF\u5929" + core : core;
310
+ }
294
311
  const restText = render(ir, rest, opts);
295
- if ((rest.kind === "clockTimes" || rest.kind === "compactClockTimes") && isDaily(ir)) {
296
- return "\u6BCF\u5929" + restText + sec;
312
+ if (rest.kind === "clockTimes" || rest.kind === "compactClockTimes") {
313
+ if (isDaily(ir)) {
314
+ return "\u6BCF\u5929" + restText + sec;
315
+ }
297
316
  }
298
317
  if (rest.kind === "singleMinute") {
299
318
  return restText + "\uFF0C" + sec;
@@ -317,6 +336,9 @@ function composeSecondsCadence(ir) {
317
336
  function composeSecondsListed(ir) {
318
337
  const sec = secondClause(ir);
319
338
  const minutes = "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206");
339
+ if (ir.shapes.hour === "single" && sec === "\u6BCF\u79D2") {
340
+ return hourWord(hourFires(ir)[0]) + valueList(fieldSegments(ir, "minute"), "\u5206") + "\u7684\u6BCF\u4E00\u79D2";
341
+ }
320
342
  if (ir.shapes.hour === "wildcard") {
321
343
  return minutes + "\uFF0C" + sec;
322
344
  }
@@ -326,10 +348,13 @@ function composeSecondsListed(ir) {
326
348
  return hourFrame(ir) + minutes + "\uFF0C" + sec;
327
349
  }
328
350
  function renderComposeSeconds(ir, plan, opts) {
329
- if (ir.pattern.minute === "0") {
351
+ const { rest } = plan;
352
+ const composedClock = rest.kind === "clockTimes" || rest.kind === "compactClockTimes";
353
+ if (ir.pattern.minute === "0" || composedClock && ir.shapes.minute === "single") {
330
354
  return composeSecondsOnHour(ir, plan, opts);
331
355
  }
332
- if (ir.pattern.minute === "*" || ir.shapes.minute === "step") {
356
+ const minuteCadence = ir.pattern.minute === "*" || ir.shapes.minute === "step" && stepSegment(ir, "minute").startToken === "*";
357
+ if (minuteCadence) {
333
358
  return composeSecondsCadence(ir);
334
359
  }
335
360
  return composeSecondsListed(ir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronli5",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Cron Like I'm Five: A Cron to English Utility",
5
5
  "repository": {
6
6
  "type": "git",
@@ -62,6 +62,7 @@
62
62
  "docs": "node --import tsx scripts/docs.mjs",
63
63
  "fuzz": "node --import tsx scripts/fuzz-lang.mjs",
64
64
  "lint": "eslint src test cli.js eslint.config.js scripts tooling/scripts",
65
+ "metamorphic": "node --import tsx tooling/scripts/metamorphic.mjs",
65
66
  "lint:fix": "eslint src test cli.js eslint.config.js scripts tooling/scripts --fix",
66
67
  "test": "mocha",
67
68
  "types": "tsc -p tsconfig.types.json",
@@ -67,10 +67,13 @@ function normalizeField(value: string, field: Field, spec: FieldSpec): string {
67
67
 
68
68
  const cycle = timeFieldCycle[field];
69
69
  const segments = stringValue.split(',').map(function canonical(segment) {
70
- return enumerateNonUniformStep(
71
- collapseDegenerateRange(
72
- collapseOnceStep(collapseUnitStep(segment, spec), spec), spec),
73
- spec, cycle);
70
+ return canonicalizeTokens(collapseFullSpanRange(
71
+ enumerateNonUniformStep(
72
+ collapseFullSpanStep(
73
+ collapseDegenerateRange(
74
+ collapseOnceStep(collapseUnitStep(segment, spec), spec), spec),
75
+ spec),
76
+ spec, cycle), spec), spec);
74
77
  }).join(',').split(',');
75
78
 
76
79
  // A full-cycle segment covers the whole field.
@@ -83,6 +86,40 @@ function normalizeField(value: string, field: Field, spec: FieldSpec): string {
83
86
  }).join(',');
84
87
  }
85
88
 
89
+ // Rewrite a segment's value tokens to their canonical numbers: a name
90
+ // (`MON`, `JAN`) becomes its index, and a weekday `7` (Sunday again, above
91
+ // `top`) folds to the field minimum (`0`). Applied to every token position —
92
+ // a single, both range bounds, and a step's start — so a normalized field
93
+ // never carries a surface name or the 7-for-Sunday alias. `*` and Quartz
94
+ // tokens (resolved earlier) are left untouched. Output is unchanged: a
95
+ // renderer maps the number back to its own name.
96
+ function canonicalizeTokens(segment: string, spec: FieldSpec): string {
97
+ // Only the named fields (month, weekday) carry name tokens or the
98
+ // weekday `7`-for-Sunday alias; every other field is already numeric.
99
+ if (!spec.numbers) {
100
+ return segment;
101
+ }
102
+
103
+ const parts = segment.split('/');
104
+ const start = parts[0].split('-').map(function fold(token) {
105
+ return canonicalizeToken(token, spec);
106
+ }).join('-');
107
+
108
+ return parts.length === 2 ? start + '/' + parts[1] : start;
109
+ }
110
+
111
+ // A single token to its canonical number string (`*` passes through).
112
+ function canonicalizeToken(token: string, spec: FieldSpec): string {
113
+ if (token === '*') {
114
+ return token;
115
+ }
116
+
117
+ const number = toFieldNumber(token, spec.numbers);
118
+
119
+ // A value above `top` (weekday 7) is the field minimum again.
120
+ return '' + (number > (spec.top as number) ? spec.min : number);
121
+ }
122
+
86
123
  // An interval-one step enumerates every value from its start, so it reads
87
124
  // as the equivalent range: `1/1` is `1-59` and `5-30/1` is `5-30`. A start
88
125
  // at the bottom of the cycle covers the whole field (`0/1` is `*`).
@@ -163,6 +200,59 @@ function enumerateNonUniformStep(
163
200
  return fires.join(',');
164
201
  }
165
202
 
203
+ // A step whose range part covers the whole field (`0-59/2`, `0-23/2`) strides
204
+ // across the entire cycle, so the bound adds nothing — it reads as the
205
+ // unbounded `*/N` step (which the cycle test then renders as "every N" or its
206
+ // fires). Partial-range steps (`9-17/2`) keep their window.
207
+ function collapseFullSpanStep(segment: string, spec: FieldSpec): string {
208
+ const parts = segment.split('/');
209
+
210
+ if (parts.length !== 2 || !includes(parts[0], '-')) {
211
+ return segment;
212
+ }
213
+
214
+ return collapseFullSpanRange(parts[0], spec) === '*' ?
215
+ '*/' + parts[1] :
216
+ segment;
217
+ }
218
+
219
+ // A plain range whose enumerated values cover the whole field imposes no
220
+ // restriction, so it reads identically to `*` (`0-59` minute, `0-23` hour,
221
+ // `1-31` date, `1-12` month, and every seven-day weekday range — `0-6`,
222
+ // `1-7`, `0-7`, `SUN-SAT` — since cron's 7 is Sunday again, folding to the
223
+ // field minimum). Only bare ranges qualify: a step (`0-59/2`) keeps its
224
+ // cadence, so segments carrying a `/` are left untouched.
225
+ function collapseFullSpanRange(segment: string, spec: FieldSpec): string {
226
+ if (typeof spec.top !== 'number' || includes(segment, '/') ||
227
+ !includes(segment, '-')) {
228
+ return segment;
229
+ }
230
+
231
+ const bounds = segment.split('-');
232
+ const low = toFieldNumber(bounds[0], spec.numbers);
233
+ const high = toFieldNumber(bounds[1], spec.numbers);
234
+
235
+ if (low > high) {
236
+ return segment;
237
+ }
238
+
239
+ // The full field is min..top; a value above top (weekday 7) folds to min.
240
+ const top = spec.top as number;
241
+ const fired: Record<number, boolean> = {};
242
+
243
+ for (let value = low; value <= high; value += 1) {
244
+ fired[value > top ? spec.min : value] = true;
245
+ }
246
+
247
+ for (let value = spec.min; value <= top; value += 1) {
248
+ if (!fired[value]) {
249
+ return segment;
250
+ }
251
+ }
252
+
253
+ return '*';
254
+ }
255
+
166
256
  // A degenerate range (`9-9`) fires once, so it reads as its single value.
167
257
  // A stepped degenerate range (`9-9/5`) likewise fires only at its start.
168
258
  function collapseDegenerateRange(segment: string, spec: FieldSpec): string {
@@ -2,6 +2,8 @@
2
2
  // German. Anchored to Duden; see notes.md for the decisions.
3
3
 
4
4
  import {pad} from '../../core/format.js';
5
+ import {weekdayNumbers} from '../../core/specs.js';
6
+ import {toFieldNumber} from '../../core/util.js';
5
7
  import type {Cronli5Options} from '../../types.js';
6
8
  import type {
7
9
  Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
@@ -61,11 +63,6 @@ const weekdayNames = [
61
63
  'freitags', 'samstags'
62
64
  ];
63
65
 
64
- // Cron weekday tokens (part of cron syntax), mapped to indices.
65
- const weekdayTokens: {[token: string]: number} = {
66
- SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6
67
- };
68
-
69
66
  function fieldSegments(ir: IR, field: Field): Segment[] {
70
67
  return ir.analyses.segments[field] as Segment[];
71
68
  }
@@ -94,14 +91,9 @@ function joinList(items: string[]): string {
94
91
  return items.slice(0, -1).join(', ') + ' und ' + items[items.length - 1];
95
92
  }
96
93
 
97
- // The adverbial name for a weekday token (cron name or number; 7 = Sunday).
94
+ // The adverbial name for a canonical weekday number (0 = Sunday).
98
95
  function weekdayName(token: NameToken): string {
99
- if (token === '7' || token === 7) {
100
- return weekdayNames[0];
101
- }
102
-
103
- return weekdayNames[token as number] ||
104
- weekdayNames[weekdayTokens[token as string]];
96
+ return weekdayNames[+token];
105
97
  }
106
98
 
107
99
  // "montags bis freitags".
@@ -150,12 +142,10 @@ function everyNthHour(segment: StepSegment): string {
150
142
  return start === 0 ? base : base + ' ab ' + start + ' Uhr';
151
143
  }
152
144
 
145
+ // The Quartz weekday stem (`5L`, `MON#2`) is not number-canonicalized in the
146
+ // core, so it may still be a name token; resolve it via the core's index.
153
147
  function weekdayNoun(token: string): string {
154
- if (token === '7') {
155
- return weekdayNouns[0];
156
- }
157
-
158
- return weekdayNouns[token in weekdayTokens ? weekdayTokens[token] : +token];
148
+ return weekdayNouns[toFieldNumber(token, weekdayNumbers)];
159
149
  }
160
150
 
161
151
  // The Quartz weekday phrase: "am letzten Freitag des Monats", "am zweiten
@@ -193,18 +183,12 @@ function quartzDate(field: string): string | null {
193
183
  return null;
194
184
  }
195
185
 
196
- // Cron month tokens (part of cron syntax), mapped to indices. The month names
197
- // themselves are dialect-scoped and resolved from `opts.style.months`.
198
- const monthTokens: {[token: string]: number} = {
199
- JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
200
- JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12
201
- };
202
-
203
186
  type Months = GermanStyle['months'];
204
187
 
188
+ // The month names are dialect-scoped (resolved from `opts.style.months`);
189
+ // the canonical month number indexes them.
205
190
  function monthName(token: NameToken, months: Months): string {
206
- return (months[token as number] ||
207
- months[monthTokens[token as string]]) as string;
191
+ return months[+token] as string;
208
192
  }
209
193
 
210
194
  // "von Juni bis August".
@@ -511,12 +495,33 @@ function renderSecondsWithinMinute(
511
495
  ' jeder Stunde';
512
496
  }
513
497
 
514
- // A minute span inside one hour: "jede Minute von 9:00 bis 9:30 Uhr".
498
+ // The whole-hour noun in the genitive: "der Mitternachtsstunde" (0), "der
499
+ // Mittagsstunde" (12), or "der <H>-Uhr-Stunde" for any other hour.
500
+ function wholeHour(hour: number): string {
501
+ if (hour === 0) {
502
+ return 'der Mitternachtsstunde';
503
+ }
504
+
505
+ if (hour === 12) {
506
+ return 'der Mittagsstunde';
507
+ }
508
+
509
+ return 'der ' + hour + '-Uhr-Stunde';
510
+ }
511
+
512
+ // A minute span inside one hour: "jede Minute von 9:00 bis 9:30 Uhr". A
513
+ // wildcard minute is the whole hour, so it reads as that hour itself ("jede
514
+ // Minute der 9-Uhr-Stunde") rather than a synthesized "von 9:00 bis 9:59"
515
+ // range the source never stated; a plain range is a real window and keeps it.
515
516
  function renderMinuteSpanInHour(
516
517
  ir: IR,
517
518
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
518
519
  opts: Opts
519
520
  ): string {
521
+ if (ir.pattern.minute === '*') {
522
+ return 'jede Minute ' + wholeHour(plan.hour);
523
+ }
524
+
520
525
  const sep = opts.style.sep;
521
526
 
522
527
  return 'jede Minute von ' + spanTime(plan.hour, plan.span[0], sep) +
@@ -530,9 +535,47 @@ function renderComposeSeconds(
530
535
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
531
536
  opts: Opts
532
537
  ): string {
538
+ // A sub-minute second with the minute pinned to 0 and a specific hour: the
539
+ // clock-time rest would read "um 9 Uhr", which hides the pinned :00 (and so
540
+ // the one-minute confinement — 60 fires in :00, not 3,600 across the hour).
541
+ // Bind the seconds into the explicit clock minute in the genitive ("der
542
+ // Minute 9:00"); the recurring "täglich"/day frame is added in `describe`.
543
+ if (composeMinuteZero(ir, plan)) {
544
+ return secondsLead(ir) + ' ' +
545
+ clockMinuteGenitive(plan.rest.times, opts.style.sep);
546
+ }
547
+
533
548
  return secondsLead(ir) + ', ' + render(ir, plan.rest, opts);
534
549
  }
535
550
 
551
+ // True when a compose-seconds plan is a sub-minute second over a minute-0
552
+ // clock-time rest — the case that reads as the bare hour and so must surface
553
+ // the pinned clock minute.
554
+ function composeMinuteZero(
555
+ ir: IR,
556
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
557
+ ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
558
+ {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
559
+ return plan.rest.kind === 'clockTimes' &&
560
+ plan.rest.times.every((time) => +time.minute === 0);
561
+ }
562
+
563
+ // The pinned clock minute in the genitive: "der Minute 9:00" for one hour,
564
+ // "der Minuten 9:00, 10:00 und 17:00" for several — the explicit ":00" so the
565
+ // minute-0 confinement stays visible.
566
+ function clockMinuteGenitive(
567
+ times: {hour: number; minute: number}[],
568
+ sep: string
569
+ ): string {
570
+ const clocks = times.map(function clock(time): string {
571
+ return time.hour + sep + pad(time.minute);
572
+ });
573
+
574
+ return clocks.length === 1 ?
575
+ 'der Minute ' + clocks[0] :
576
+ 'der Minuten ' + joinList(clocks);
577
+ }
578
+
536
579
  // A minute clause across discrete hours: "in den Minuten 0 bis 30, um 9 und
537
580
  // 17 Uhr".
538
581
  function renderMinutesAcrossHours(
@@ -741,15 +784,31 @@ function qualifier(ir: IR, months: Months): string {
741
784
  }
742
785
 
743
786
  // Plan kinds whose clause is a clock time: the qualifier leads them ("montags
744
- // um 9 Uhr"); a frequency clause trails it ("jede Minute montags").
787
+ // um 9 Uhr"); a frequency clause trails it ("jede Minute montags"). The
788
+ // minute-0 compose-seconds clause is anchored on a clock minute too, so the
789
+ // qualifier leads it ("montags jede Sekunde der Minute 9:00").
745
790
  const LEADING_PLANS = new Set(['clockTimes']);
746
791
 
792
+ // True when the leading qualifier should precede the clause: a clock-time
793
+ // plan, or the minute-0 compose-seconds clause that surfaces a clock minute.
794
+ function leadsQualifier(ir: IR): boolean {
795
+ return LEADING_PLANS.has(ir.plan.kind) || isComposeMinuteZero(ir);
796
+ }
797
+
798
+ // Whether the planned clause is the minute-0 compose-seconds confinement
799
+ // (a sub-minute second over a minute-0 clock-time rest).
800
+ function isComposeMinuteZero(ir: IR): boolean {
801
+ return ir.plan.kind === 'composeSeconds' &&
802
+ composeMinuteZero(ir, ir.plan);
803
+ }
804
+
747
805
  // True when the clause is a bare daily clock-time list and so needs the
748
- // "täglich" frame to read as recurring, not a one-off: clockTimes always, and
749
- // an uneven hour step (rendered as its fire list "um 0, 5, Uhr", not the
750
- // cadence "alle N Stunden"). A frequency clause already implies recurrence.
806
+ // "täglich" frame to read as recurring, not a one-off: clockTimes always, the
807
+ // minute-0 compose-seconds clause (a recurring clock minute), and an uneven
808
+ // hour step (rendered as its fire list "um 0, 5, Uhr", not the cadence "alle
809
+ // N Stunden"). A frequency clause already implies recurrence.
751
810
  function needsDailyFrame(ir: IR): boolean {
752
- if (ir.plan.kind === 'clockTimes') {
811
+ if (ir.plan.kind === 'clockTimes' || isComposeMinuteZero(ir)) {
753
812
  return true;
754
813
  }
755
814
 
@@ -804,7 +863,7 @@ function describe(ir: IR, opts: Opts): string {
804
863
  let base = core;
805
864
 
806
865
  if (qual) {
807
- base = LEADING_PLANS.has(ir.plan.kind) ?
866
+ base = leadsQualifier(ir) ?
808
867
  qual + ' ' + core :
809
868
  core + ' ' + qual;
810
869
  }
@@ -21,12 +21,15 @@ type StepSegment = Extract<Segment, {kind: 'step'}>;
21
21
 
22
22
  // A clock-time entry assembled for rendering. Hour/minute/second arrive as
23
23
  // numbers or as raw field tokens (a range bound or single value is a
24
- // string); `plain` suppresses the noon/midnight words.
24
+ // string); `plain` suppresses the noon/midnight words. `explicit` forces the
25
+ // minute to show even when zero ("9:00 a.m.", not "9 a.m.") and suppresses
26
+ // the noon/midnight words, so a pinned minute-0 stays visible.
25
27
  interface TimeEntry {
26
28
  hour: number | string;
27
29
  minute: number | string;
28
30
  second?: number | string | null;
29
31
  plain?: boolean;
32
+ explicit?: boolean;
30
33
  }
31
34
 
32
35
  // English number names for the integers zero through ten.
@@ -80,22 +83,6 @@ const weekdayNames: [string, string][] = [
80
83
  ['Saturday', 'Sat']
81
84
  ];
82
85
 
83
- // Month names by abbreviation.
84
- const monthAbbreviations: Record<string, [string, string] | null> = {
85
- JAN: monthNames[1],
86
- FEB: monthNames[2],
87
- MAR: monthNames[3],
88
- APR: monthNames[4],
89
- MAY: monthNames[5],
90
- JUN: monthNames[6],
91
- JUL: monthNames[7],
92
- AUG: monthNames[8],
93
- SEP: monthNames[9],
94
- OCT: monthNames[10],
95
- NOV: monthNames[11],
96
- DEC: monthNames[12]
97
- };
98
-
99
86
  // Weekday name by abbreviation.
100
87
  const weekdayAbbreviations: Record<string, [string, string]> = {
101
88
  SUN: weekdayNames[0],
@@ -192,9 +179,78 @@ function renderSecondsWithinMinute(ir: IR, plan: PlanOf<'secondsWithinMinute'>,
192
179
  // pattern follows.
193
180
  function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
194
181
  opts: NormalizedOptions): string {
182
+ // A wildcard or stepped second under a minute pinned to a single value
183
+ // across one or more specific hours. The clock-time rest collapses the
184
+ // pinned minute into the hour, and on the clock a pinned minute-0 reads as
185
+ // the whole hour ("9 a.m." spoken == "9:00 a.m."), losing the one-minute
186
+ // confinement. (A second list/range/single leads with a "past the minute"
187
+ // clause that an "of"/duration frame cannot follow, so it stays generic.)
188
+ if (plan.rest.kind === 'clockTimes' &&
189
+ (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
190
+ const minute = plan.rest.times[0].minute;
191
+
192
+ // Minute 0 is the one-minute window at the top of each named hour: a
193
+ // duration frame ("for one minute at 9 a.m.") states the confinement
194
+ // outright, with the hour as its word so it cannot be heard as the hour
195
+ // itself. A non-zero pinned minute is an unambiguous clock time, so the
196
+ // compact "of 9:05 a.m." form reads it as the minute, never the hour.
197
+ if (+minute === 0) {
198
+ return secondsLeadClause(ir, opts) + ' for one minute at ' +
199
+ durationHours(ir, plan.rest, opts);
200
+ }
201
+
202
+ return secondsLeadClause(ir, opts) + ' of ' +
203
+ clockTimesOf(ir, plan.rest, opts);
204
+ }
205
+
206
+ // A wildcard second under a */2 minute step with a wildcard hour binds
207
+ // idiomatically as "every second of every other minute": "every other" is
208
+ // the natural English for an interval of 2, and "of" joins the two without
209
+ // the ambiguity of a comma, which reads as two independent cadences.
210
+ // Scoped to */2 only; other step sizes keep the comma form.
211
+ if (ir.shapes.second === 'wildcard' &&
212
+ plan.rest.kind === 'minuteFrequency' &&
213
+ plan.rest.hours.kind === 'none' &&
214
+ ir.pattern.minute === '*/2') {
215
+ return 'every second of every other minute' +
216
+ trailingQualifier(ir, opts);
217
+ }
218
+
195
219
  return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
196
220
  }
197
221
 
222
+ // The bare-hour words for a minute-0 duration confinement, joined and followed
223
+ // by the trailing day qualifier: "9 a.m. and 11 a.m., every day", "midnight,
224
+ // 2 a.m., …, every day". The hour reads as its word (noon/midnight included),
225
+ // never "H:00", since the "for one minute" frame already carries the minute.
226
+ function durationHours(ir: IR, plan: PlanOf<'clockTimes'>,
227
+ opts: NormalizedOptions): string {
228
+ const hours = plan.times.map(function clock(time) {
229
+ return getTime({hour: time.hour, minute: 0}, opts);
230
+ });
231
+ const trail = dayQualifier(ir, leadingWords, opts);
232
+
233
+ return joinList(hours, opts) + (trail && ', ' + trail);
234
+ }
235
+
236
+ // The clock times for a non-zero pinned-minute compose-seconds rest, joined
237
+ // and followed by the trailing day qualifier: "9:05 a.m. and 11:05 a.m.,
238
+ // every day". The non-zero minute reads as a clock time, never the hour.
239
+ function clockTimesOf(ir: IR, plan: PlanOf<'clockTimes'>,
240
+ opts: NormalizedOptions): string {
241
+ const times = plan.times.map(function clock(time) {
242
+ return getTime({
243
+ hour: time.hour,
244
+ minute: time.minute,
245
+ second: time.second,
246
+ explicit: true
247
+ }, opts);
248
+ });
249
+ const trail = dayQualifier(ir, leadingWords, opts);
250
+
251
+ return joinList(times, opts) + (trail && ', ' + trail);
252
+ }
253
+
198
254
  // The leading clause describing a second field relative to the minute,
199
255
  // e.g. "at 5 and 10 seconds past the minute" or "every second from zero
200
256
  // through 30 past the minute".
@@ -289,9 +345,18 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
289
345
  }
290
346
 
291
347
  // A minute wildcard or plain range under a single specific hour fires
292
- // every minute within a window inside that hour.
348
+ // every minute within a window inside that hour. A wildcard minute is the
349
+ // whole hour, so it reads as that hour itself ("every minute of the 9 a.m.
350
+ // hour") rather than a synthesized "from H:00 through H:59" range the source
351
+ // never stated; a plain range is a real window and keeps "from … through …".
293
352
  function renderMinuteSpanInHour(ir: IR, plan: PlanOf<'minuteSpanInHour'>,
294
353
  opts: NormalizedOptions): string {
354
+ if (ir.pattern.minute === '*') {
355
+ return 'every minute of the ' +
356
+ getTime({hour: plan.hour, minute: 0}, opts) + ' hour' +
357
+ trailingQualifier(ir, opts);
358
+ }
359
+
295
360
  return 'every minute from ' +
296
361
  getTime({hour: plan.hour, minute: plan.span[0]}, opts) +
297
362
  through(opts) + getTime({hour: plan.hour, minute: plan.span[1]}, opts) +
@@ -1169,7 +1234,7 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
1169
1234
  // "3.45pm" / "9am" / "midday" for UK (Guardian), or "15:45" / "15.45" in
1170
1235
  // 24-hour mode.
1171
1236
  function getTime(time: TimeEntry, opts: NormalizedOptions): string {
1172
- const {hour, minute, plain} = time;
1237
+ const {hour, minute, plain, explicit} = time;
1173
1238
  // Seconds are only shown when a specific non-zero value is supplied.
1174
1239
  const second = typeof time.second === 'number' && time.second > 0 ?
1175
1240
  time.second :
@@ -1179,12 +1244,13 @@ function getTime(time: TimeEntry, opts: NormalizedOptions): string {
1179
1244
  // Hour/minute arrive as numbers or raw field tokens (a range bound or
1180
1245
  // single value is a string); `clockDigits` types them as numbers but
1181
1246
  // `pad` stringifies either form to the same digits. Cast to keep the
1182
- // value byte-identical rather than coercing it.
1247
+ // value byte-identical rather than coercing it. The 24-hour form always
1248
+ // shows the minute, so it is already explicit.
1183
1249
  return clockDigits({hour: hour as number, minute: minute as number,
1184
1250
  second}, {pad: true, sep: opts.style.sep});
1185
1251
  }
1186
1252
 
1187
- return twelveHourTime({hour, minute, second, plain}, opts);
1253
+ return twelveHourTime({hour, minute, second, plain, explicit}, opts);
1188
1254
  }
1189
1255
 
1190
1256
  // The 12-hour form of a clock time: "9:30 a.m.", "9 a.m." on the hour, or
@@ -1193,13 +1259,13 @@ function getTime(time: TimeEntry, opts: NormalizedOptions): string {
1193
1259
  // stays in one number style.
1194
1260
  function twelveHourTime(
1195
1261
  time: {hour: number | string; minute: number | string; second: number;
1196
- plain?: boolean},
1262
+ plain?: boolean; explicit?: boolean},
1197
1263
  opts: NormalizedOptions
1198
1264
  ): string {
1199
- const {hour, minute, second, plain} = time;
1265
+ const {hour, minute, second, plain, explicit} = time;
1200
1266
  const style = opts.style;
1201
1267
 
1202
- if (!plain && +minute === 0 && !second) {
1268
+ if (!plain && !explicit && +minute === 0 && !second) {
1203
1269
  if (+hour === 0) {
1204
1270
  return style.midnight;
1205
1271
  }
@@ -1211,9 +1277,11 @@ function twelveHourTime(
1211
1277
 
1212
1278
  // `hour`/`minute` may be raw field tokens; the arithmetic below coerces
1213
1279
  // them numerically, matching `clockDigits`. Cast for the modulo/compare.
1280
+ // `explicit` keeps the minute (":00") rather than leaning down to the bare
1281
+ // hour, so a pinned minute-0 stays visible.
1214
1282
  const digits = clockDigits(
1215
1283
  {hour: (hour as number) % 12 || 12, minute: minute as number, second},
1216
- {lean: true, sep: style.sep});
1284
+ {lean: !explicit, sep: style.sep});
1217
1285
 
1218
1286
  return digits + (style.closeUp ? '' : ' ') +
1219
1287
  ((hour as number) < 12 ? style.am : style.pm);
@@ -1255,11 +1323,10 @@ function getOrdinal(n: number | string): string {
1255
1323
  return n + suffix;
1256
1324
  }
1257
1325
 
1258
- // Get English month names from a number or from an abbreviation.
1326
+ // Get English month names from a canonical month number (months are never
1327
+ // Quartz, so the field is always number-canonicalized by the core).
1259
1328
  function getMonth(m: number | string, opts: NormalizedOptions): string {
1260
- // `m` is a month number (indexing `monthNames`) or an abbreviation token
1261
- // (indexing `monthAbbreviations`); the unmatched table yields undefined.
1262
- const month = monthNames[m as number] || monthAbbreviations[m];
1329
+ const month = monthNames[+m];
1263
1330
 
1264
1331
  // A valid month always resolves to a name pair, so the guarded lookup is
1265
1332
  // a string; the cast keeps the original null-guard expression intact.
@@ -1287,7 +1354,10 @@ const en: Language = {
1287
1354
  fallback: 'an unrecognizable cron pattern',
1288
1355
  options: normalizeOptions,
1289
1356
  reboot: 'at system startup',
1290
- sentence: (description) => 'Runs ' + description + '.'
1357
+ // A description ending in an abbreviation already carries its period
1358
+ // ("…9 a.m."), so closing the sentence must not double it.
1359
+ sentence: (description) =>
1360
+ 'Runs ' + description + (description.endsWith('.') ? '' : '.')
1291
1361
  };
1292
1362
 
1293
1363
  export default en;