cronli5 0.1.1 → 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.
@@ -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) +
@@ -547,7 +612,10 @@ const renderers = {
547
612
  // Phrase a `start/interval` step segment for a field that cycles every 60
548
613
  // units (seconds and minutes). `unit` is the singular noun and `anchor` is
549
614
  // the larger unit the values are counted against. Interval-one steps never
550
- // arrive here: normalization collapses them to ranges or `*`.
615
+ // arrive here: normalization collapses them to ranges or `*`. Nor do uneven
616
+ // steps that fail to tile the cycle: normalization rewrites those to the
617
+ // literal list of their fires, so only a clean cadence (interval dividing
618
+ // 60, start within the first interval) reaches a step renderer.
551
619
  function stepCycle60(segment: StepSegment, unit: string,
552
620
  anchor: string, opts: NormalizedOptions): string {
553
621
  // A bounded start (`a-b/n`) applies the interval within the range.
@@ -559,6 +627,8 @@ function stepCycle60(segment: StepSegment, unit: string,
559
627
  const interval = segment.interval;
560
628
 
561
629
  if (start !== 0) {
630
+ // A short offset cadence lists its fires; a longer one names the
631
+ // interval and its starting offset ("every six minutes from five …").
562
632
  if (segment.fires.length <= 3) {
563
633
  return listPastThe(numberWords(segment.fires, opts), unit, anchor,
564
634
  opts);
@@ -569,18 +639,8 @@ function stepCycle60(segment: StepSegment, unit: string,
569
639
  ' past the ' + anchor;
570
640
  }
571
641
 
572
- // A step reads as a natural cadence ("every N minutes") only when it
573
- // divides the cycle evenly, mirroring the hour field's `24 % n` rule.
574
- if (60 % interval === 0) {
575
- return 'every ' + getNumber(interval, opts) + ' ' + unit + 's';
576
- }
577
-
578
- if (segment.fires.length <= 2) {
579
- return listPastThe(numberWords(segment.fires, opts), unit, anchor, opts);
580
- }
581
-
582
- return 'every ' + getNumber(interval, opts) + ' ' + unit +
583
- 's past the ' + anchor;
642
+ // A clean stride from the top of the cycle is the bare cadence.
643
+ return 'every ' + getNumber(interval, opts) + ' ' + unit + 's';
584
644
  }
585
645
 
586
646
  // Phrase a `start/interval` step segment for the hour field (cycles every
@@ -594,18 +654,18 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
594
654
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
595
655
  const interval = segment.interval;
596
656
 
597
- if (start === 0 && 24 % interval === 0) {
657
+ // A clean stride from midnight is the bare cadence. (An uneven stride is
658
+ // rewritten to its fires upstream and never reaches here.)
659
+ if (start === 0) {
598
660
  return 'every ' + getNumber(interval, opts) + ' hours';
599
661
  }
600
662
 
663
+ // A short offset cadence lists its fires; a longer one names the interval
664
+ // and its start ("every three hours from 2 a.m.").
601
665
  if (segment.fires.length <= 3) {
602
666
  return 'at ' + hourTimes(segment.fires, opts);
603
667
  }
604
668
 
605
- if (start === 0) {
606
- return 'every ' + getNumber(interval, opts) + ' hours from midnight';
607
- }
608
-
609
669
  return 'every ' + getNumber(interval, opts) + ' hours from ' +
610
670
  getTime({hour: start, minute: 0}, opts);
611
671
  }
@@ -1174,7 +1234,7 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
1174
1234
  // "3.45pm" / "9am" / "midday" for UK (Guardian), or "15:45" / "15.45" in
1175
1235
  // 24-hour mode.
1176
1236
  function getTime(time: TimeEntry, opts: NormalizedOptions): string {
1177
- const {hour, minute, plain} = time;
1237
+ const {hour, minute, plain, explicit} = time;
1178
1238
  // Seconds are only shown when a specific non-zero value is supplied.
1179
1239
  const second = typeof time.second === 'number' && time.second > 0 ?
1180
1240
  time.second :
@@ -1184,12 +1244,13 @@ function getTime(time: TimeEntry, opts: NormalizedOptions): string {
1184
1244
  // Hour/minute arrive as numbers or raw field tokens (a range bound or
1185
1245
  // single value is a string); `clockDigits` types them as numbers but
1186
1246
  // `pad` stringifies either form to the same digits. Cast to keep the
1187
- // 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.
1188
1249
  return clockDigits({hour: hour as number, minute: minute as number,
1189
1250
  second}, {pad: true, sep: opts.style.sep});
1190
1251
  }
1191
1252
 
1192
- return twelveHourTime({hour, minute, second, plain}, opts);
1253
+ return twelveHourTime({hour, minute, second, plain, explicit}, opts);
1193
1254
  }
1194
1255
 
1195
1256
  // The 12-hour form of a clock time: "9:30 a.m.", "9 a.m." on the hour, or
@@ -1198,13 +1259,13 @@ function getTime(time: TimeEntry, opts: NormalizedOptions): string {
1198
1259
  // stays in one number style.
1199
1260
  function twelveHourTime(
1200
1261
  time: {hour: number | string; minute: number | string; second: number;
1201
- plain?: boolean},
1262
+ plain?: boolean; explicit?: boolean},
1202
1263
  opts: NormalizedOptions
1203
1264
  ): string {
1204
- const {hour, minute, second, plain} = time;
1265
+ const {hour, minute, second, plain, explicit} = time;
1205
1266
  const style = opts.style;
1206
1267
 
1207
- if (!plain && +minute === 0 && !second) {
1268
+ if (!plain && !explicit && +minute === 0 && !second) {
1208
1269
  if (+hour === 0) {
1209
1270
  return style.midnight;
1210
1271
  }
@@ -1216,9 +1277,11 @@ function twelveHourTime(
1216
1277
 
1217
1278
  // `hour`/`minute` may be raw field tokens; the arithmetic below coerces
1218
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.
1219
1282
  const digits = clockDigits(
1220
1283
  {hour: (hour as number) % 12 || 12, minute: minute as number, second},
1221
- {lean: true, sep: style.sep});
1284
+ {lean: !explicit, sep: style.sep});
1222
1285
 
1223
1286
  return digits + (style.closeUp ? '' : ' ') +
1224
1287
  ((hour as number) < 12 ? style.am : style.pm);
@@ -1260,11 +1323,10 @@ function getOrdinal(n: number | string): string {
1260
1323
  return n + suffix;
1261
1324
  }
1262
1325
 
1263
- // 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).
1264
1328
  function getMonth(m: number | string, opts: NormalizedOptions): string {
1265
- // `m` is a month number (indexing `monthNames`) or an abbreviation token
1266
- // (indexing `monthAbbreviations`); the unmatched table yields undefined.
1267
- const month = monthNames[m as number] || monthAbbreviations[m];
1329
+ const month = monthNames[+m];
1268
1330
 
1269
1331
  // A valid month always resolves to a name pair, so the guarded lookup is
1270
1332
  // a string; the cast keeps the original null-guard expression intact.
@@ -1292,7 +1354,10 @@ const en: Language = {
1292
1354
  fallback: 'an unrecognizable cron pattern',
1293
1355
  options: normalizeOptions,
1294
1356
  reboot: 'at system startup',
1295
- 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('.') ? '' : '.')
1296
1361
  };
1297
1362
 
1298
1363
  export default en;
@@ -9,6 +9,8 @@
9
9
  // lists render as per-hour windows).
10
10
 
11
11
  import {clockDigits, numeral} from '../../core/format.js';
12
+ import {weekdayNumbers} from '../../core/specs.js';
13
+ import {toFieldNumber} from '../../core/util.js';
12
14
  import type {Cronli5Options} from '../../types.js';
13
15
  import type {
14
16
  Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
@@ -109,16 +111,6 @@ const weekdayNames = [
109
111
  'sábado'
110
112
  ];
111
113
 
112
- // Cron token vocabulary (JAN..DEC, SUN..SAT) is part of cron syntax; map
113
- // it to Spanish names.
114
- const monthTokens: {[token: string]: number} = {
115
- JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
116
- JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12
117
- };
118
- const weekdayTokens: {[token: string]: number} = {
119
- SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6
120
- };
121
-
122
114
  // Ordinals for Quartz `#` weekday occurrences (1-5).
123
115
  const nthWeekdayNames =
124
116
  [null, 'primer', 'segundo', 'tercer', 'cuarto', 'quinto'];
@@ -219,6 +211,13 @@ function renderComposeSeconds(
219
211
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
220
212
  opts: Opts
221
213
  ): string {
214
+ // A wildcard or stepped second with the minute pinned to a single value
215
+ // across one or more specific hours: the seconds confine to the clock time.
216
+ if (plan.rest.kind === 'clockTimes' &&
217
+ (ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
218
+ return pinnedMinuteSeconds(ir, plan.rest, opts);
219
+ }
220
+
222
221
  // Seconds list + fixed clock time: nest the seconds into the clock time(s)
223
222
  // with genitive "de las HH:MM" instead of "de cada minuto"; the minute is
224
223
  // fixed so "de cada minuto" is misleading. Single seconds already fold into
@@ -256,6 +255,33 @@ function renderComposeSeconds(
256
255
  return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
257
256
  }
258
257
 
258
+ // A wildcard or stepped second under a single pinned minute and specific
259
+ // hour(s). The clock-time rest folds the minute into the hour, and on the
260
+ // 12-hour clock a pinned minute-0 drops the :00 entirely ("a las 9 de la
261
+ // mañana") — and even "a las 9" reads aloud as the whole hour, hiding the
262
+ // one-minute confinement (60 fires in :00, not 3,600 across the hour). Minute
263
+ // 0 is the one-minute window at the top of each named hour: a duration frame
264
+ // ("durante un minuto a las 9") states the confinement outright, with the hour
265
+ // as a bare hour so it cannot be heard as the whole hour. A non-zero pinned
266
+ // minute is an unambiguous clock time, so the genitive "de las 09:05" form
267
+ // reads it as the minute, never the hour.
268
+ function pinnedMinuteSeconds(
269
+ ir: IR,
270
+ rest: Extract<PlanNode, {kind: 'clockTimes'}>,
271
+ opts: Opts
272
+ ): string {
273
+ const dayTrail = leadingQualifier(ir, opts).trimEnd();
274
+ const trail = dayTrail ? ', ' + dayTrail : '';
275
+
276
+ if (+rest.times[0].minute === 0) {
277
+ return secondsLeadClause(ir, opts) + ' durante un minuto ' +
278
+ durationHourList(rest.times, opts) + trail;
279
+ }
280
+
281
+ return secondsLeadClause(ir, opts) + ' de ' +
282
+ explicitClockList(rest.times, opts) + trail;
283
+ }
284
+
259
285
  // The leading clause describing a second field relative to the minute.
260
286
  function secondsLeadClause(ir: IR, opts: Opts): string {
261
287
  const secondField = ir.pattern.second;
@@ -453,12 +479,21 @@ function renderMinuteFrequency(
453
479
  return phrase + trailingQualifier(ir, opts);
454
480
  }
455
481
 
456
- // "cada minuto de las 9:00 a las 9:29 de la mañana".
482
+ // "cada minuto de las 9:00 a las 9:29 de la mañana". A wildcard minute is the
483
+ // whole hour, so it reads as that hour itself ("cada minuto de la hora de las
484
+ // 09:00") rather than a synthesized "de las HH:00 a las HH:59" range the
485
+ // source never stated; a plain range is a real window and keeps "de … a …".
457
486
  function renderMinuteSpanInHour(
458
487
  ir: IR,
459
488
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
460
489
  opts: Opts
461
490
  ): string {
491
+ if (ir.pattern.minute === '*') {
492
+ return 'cada minuto de la hora ' +
493
+ fromTime(timePhrase(plan.hour, 0, null, opts)) +
494
+ trailingQualifier(ir, opts);
495
+ }
496
+
462
497
  return 'cada minuto ' +
463
498
  timeRange({hour: plan.hour, minute: plan.span[0]},
464
499
  {hour: plan.hour, minute: plan.span[1]}, opts) +
@@ -689,6 +724,82 @@ function renderClockTimes(
689
724
  return leadingQualifier(ir, opts) + groupClockTimes(phrases);
690
725
  }
691
726
 
727
+ // The genitive clock-time list for a minute-0 compose-seconds confinement:
728
+ // each time with its minute forced visible ("las 09:00"), grouped as usual,
729
+ // then reframed from "a …" to the genitive "de …" the caller prepends. So a
730
+ // pinned minute-0 reads "de las 09:00", never the bare hour.
731
+ function explicitClockList(
732
+ times: {hour: number; minute: number; second?: number | null}[],
733
+ opts: Opts
734
+ ): string {
735
+ const phrases = times.map(function clock(time) {
736
+ return atTime(explicitTimePhrase(time.hour, time.minute, opts));
737
+ });
738
+ const grouped = groupClockTimes(phrases);
739
+
740
+ // Strip the leading "a " so the caller's "de " produces the genitive form.
741
+ return grouped.startsWith('a ') ? grouped.slice(2) : grouped;
742
+ }
743
+
744
+ // The bare-hour list for a minute-0 duration confinement, keeping the "a …"
745
+ // frame the caller embeds after "durante un minuto": "a las 9",
746
+ // "a medianoche", "a las 9, 10, 11 y 12". The hour reads as a bare hour
747
+ // (no minutes), since the "durante un minuto" frame already carries the
748
+ // one-minute window — never "las 09:00", which would read as the whole hour.
749
+ function durationHourList(
750
+ times: {hour: number; minute: number; second?: number | null}[],
751
+ opts: Opts
752
+ ): string {
753
+ const phrases = times.map(function clock(time) {
754
+ return atTime(bareHourPhrase(time.hour, opts));
755
+ });
756
+
757
+ return groupClockTimes(phrases);
758
+ }
759
+
760
+ // A bare hour with its article, no minutes: "las 9" / "la 1" / "mediodía" /
761
+ // "medianoche" on the 24-hour clock, or the 12-hour day-period form
762
+ // ("las 9 de la mañana"). Used by the minute-0 duration frame, where the
763
+ // minute is already stated and the clock minute would only mislead.
764
+ function bareHourPhrase(hour: number, opts: Opts): string {
765
+ if (opts.ampm) {
766
+ return timePhrase(hour, 0, null, opts);
767
+ }
768
+
769
+ if (+hour === 0) {
770
+ return 'medianoche';
771
+ }
772
+
773
+ if (+hour === 12) {
774
+ return 'mediodía';
775
+ }
776
+
777
+ return (+hour === 1 ? 'la ' : 'las ') + hour;
778
+ }
779
+
780
+ // A clock time with its minute forced visible and the noon/midnight words
781
+ // suppressed: "las 09:00", "las 9:00 de la mañana", "las 12:00 de la tarde".
782
+ // So a pinned minute-0 confinement always shows its ":00".
783
+ function explicitTimePhrase(hour: number, minute: number, opts: Opts): string {
784
+ if (!opts.ampm) {
785
+ const article = +hour === 1 ? 'la ' : 'las ';
786
+ const suffix = opts.style.hSuffix ? ' h' : '';
787
+
788
+ return article +
789
+ clockDigits({hour, minute, second: 0},
790
+ {pad: true, sep: opts.style.sep}) + suffix;
791
+ }
792
+
793
+ const display = hour % 12 || 12;
794
+ const time = (display === 1 ? 'la ' : 'las ') +
795
+ clockDigits({hour: display, minute, second: 0}, {sep: opts.style.sep});
796
+ const period = opts.style.meridiem === 'english' ?
797
+ meridiemMark(hour) :
798
+ dayPeriod(hour, opts);
799
+
800
+ return time + ' ' + period;
801
+ }
802
+
692
803
  // Group a chronological run of "a la(s) …" clock phrases. The 12-hour clock
693
804
  // carries day periods ("de la <period>"), which group chronologically by
694
805
  // period; the 24-hour clock has none, so it falls through to article-grouping.
@@ -1015,17 +1126,9 @@ function stepCycle60(
1015
1126
  unit + ' ' + start + ' de cada ' + anchor;
1016
1127
  }
1017
1128
 
1018
- if (60 % interval === 0) {
1019
- return 'cada ' + numero(interval, opts) + ' ' + unit + 's';
1020
- }
1021
-
1022
- if (segment.fires.length <= 2) {
1023
- return 'en los ' + unit + 's ' + joinList(wordList(segment.fires)) +
1024
- ' de cada ' + anchor;
1025
- }
1026
-
1027
- return 'cada ' + numero(interval, opts) + ' ' + unit + 's de cada ' +
1028
- anchor;
1129
+ // A clean stride from the top of the cycle is the bare cadence. (An uneven
1130
+ // stride is rewritten to its fires upstream and never reaches here.)
1131
+ return 'cada ' + numero(interval, opts) + ' ' + unit + 's';
1029
1132
  }
1030
1133
 
1031
1134
  // "cada seis horas", "a las 9:00, a las 11:00 y a la 1:00", or "cada
@@ -1038,7 +1141,9 @@ function stepHours(segment: StepSegment, opts: Opts): string {
1038
1141
  const start = segment.startToken === '*' ? 0 : +segment.startToken;
1039
1142
  const interval = segment.interval;
1040
1143
 
1041
- if (start === 0 && 24 % interval === 0) {
1144
+ // A clean stride from midnight is the bare cadence. (An uneven stride is
1145
+ // rewritten to its fires upstream and never reaches here.)
1146
+ if (start === 0) {
1042
1147
  return 'cada ' + numero(interval, opts) + ' horas';
1043
1148
  }
1044
1149
 
@@ -1046,10 +1151,6 @@ function stepHours(segment: StepSegment, opts: Opts): string {
1046
1151
  return groupClockTimesByArticle(atTimes(segment.fires, opts));
1047
1152
  }
1048
1153
 
1049
- if (start === 0) {
1050
- return 'cada ' + numero(interval, opts) + ' horas desde medianoche';
1051
- }
1052
-
1053
1154
  return 'cada ' + numero(interval, opts) + ' horas a partir de ' +
1054
1155
  timePhrase(start, 0, null, opts);
1055
1156
  }
@@ -1699,16 +1800,13 @@ function numero(n: number, opts: Opts): string | number {
1699
1800
  return numeral(n, numeros, opts);
1700
1801
  }
1701
1802
 
1702
- // A weekday name from a number or a cron token.
1803
+ // A weekday name from a canonical number, or from a Quartz stem (`5L`,
1804
+ // `MON#2`), which the core does not number-canonicalize: resolve any name
1805
+ // via the core's index and fold the Sunday alias 7 to 0.
1703
1806
  function weekdayName(token: NameToken): string {
1704
- if (token === '7' || token === 7) {
1705
- return weekdayNames[0];
1706
- }
1807
+ const number = toFieldNumber('' + token, weekdayNumbers);
1707
1808
 
1708
- // `token` may be a numeric (string or number) field index or a cron name;
1709
- // the numeric path indexes the name array, the name path the token map.
1710
- return weekdayNames[token as number] ||
1711
- weekdayNames[weekdayTokens[token as string]];
1809
+ return weekdayNames[number === 7 ? 0 : number];
1712
1810
  }
1713
1811
 
1714
1812
  // The plural weekday form: días ending in -s are invariant ("los lunes");
@@ -1719,12 +1817,10 @@ function pluralWeekday(token: NameToken): string {
1719
1817
  return name.endsWith('s') ? name : name + 's';
1720
1818
  }
1721
1819
 
1722
- // A month name from a number or a cron token.
1820
+ // A month name from a canonical month number. The name array has a leading
1821
+ // null hole for the 1-based index.
1723
1822
  function monthName(token: NameToken): string {
1724
- // As with weekdays: a numeric index hits the name array, a cron name the
1725
- // token map. The name array has a leading null hole for the 1-based index.
1726
- return (monthNames[token as number] ||
1727
- monthNames[monthTokens[token as string]]) as string;
1823
+ return monthNames[+token] as string;
1728
1824
  }
1729
1825
 
1730
1826
  // Whether a canonical field value is an open step (`*/n` or `a/n`).
@@ -1740,7 +1836,10 @@ const es: Language<SpanishStyle> = {
1740
1836
  fallback: 'un patrón cron irreconocible',
1741
1837
  options: normalizeOptions,
1742
1838
  reboot: 'al arrancar el sistema',
1743
- sentence: (description) => 'Se ejecuta ' + description + '.'
1839
+ // A description ending in a period already carries it, so closing the
1840
+ // sentence must not double it.
1841
+ sentence: (description) =>
1842
+ 'Se ejecuta ' + description + (description.endsWith('.') ? '' : '.')
1744
1843
  };
1745
1844
 
1746
1845
  export default es;