cronli5 0.1.4 → 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.
package/dist/lang/zh.js CHANGED
@@ -3,6 +3,21 @@ function isNonNegativeInteger(value) {
3
3
  const digits = /^\d+$/;
4
4
  return digits.test(value);
5
5
  }
6
+ function arithmeticStep(values) {
7
+ if (values.length < 5) {
8
+ return null;
9
+ }
10
+ const interval = values[1] - values[0];
11
+ if (interval < 2) {
12
+ return null;
13
+ }
14
+ for (let i = 2; i < values.length; i += 1) {
15
+ if (values[i] - values[i - 1] !== interval) {
16
+ return null;
17
+ }
18
+ }
19
+ return { start: values[0], interval, last: values[values.length - 1] };
20
+ }
6
21
  function toFieldNumber(token, numberMap) {
7
22
  return isNonNegativeInteger(token) ? +token : numberMap[token.toUpperCase()];
8
23
  }
@@ -31,6 +46,7 @@ var monthNumbers = {
31
46
  NOV: 11,
32
47
  DEC: 12
33
48
  };
49
+ var maxClockTimes = 6;
34
50
 
35
51
  // src/lang/zh/dialects.ts
36
52
  var zh = { variant: "Hans" };
@@ -61,6 +77,46 @@ function stepSegment(ir, field) {
61
77
  function cadence(interval, unit) {
62
78
  return interval === 1 ? "\u6BCF" + unit : "\u6BCF" + interval + unit;
63
79
  }
80
+ function renderStride(stride) {
81
+ const { interval, start, last, cycle, unit, mark, anchor } = stride;
82
+ const tiles = cycle % interval === 0;
83
+ if (start === 0 && tiles) {
84
+ return cadence(interval, unit);
85
+ }
86
+ const lead = anchor + "\u4ECE" + start + mark + "\u8D77" + cadence(interval, unit);
87
+ return start < interval && tiles ? lead : lead + "\uFF0C\u81F3" + last + mark;
88
+ }
89
+ function singleValues(segments) {
90
+ const values = [];
91
+ for (const segment of segments) {
92
+ if (segment.kind !== "single") {
93
+ return null;
94
+ }
95
+ values.push(+segment.value);
96
+ }
97
+ return values;
98
+ }
99
+ function strideFromSegments(segments, unit, mark, anchor) {
100
+ const values = singleValues(segments);
101
+ const step = values && arithmeticStep(values);
102
+ return step ? renderStride({ ...step, cycle: 60, unit, mark, anchor }) : null;
103
+ }
104
+ function stepClause(segment, unit, mark, anchor) {
105
+ const start = segment.startToken === "*" ? 0 : +segment.startToken;
106
+ const short = start !== 0 && segment.fires.length <= 3;
107
+ if (segment.startToken.indexOf("-") !== -1 || short) {
108
+ return anchor + segment.fires.join("\u3001") + mark;
109
+ }
110
+ return renderStride({
111
+ interval: segment.interval,
112
+ start,
113
+ last: segment.fires[segment.fires.length - 1],
114
+ cycle: 60,
115
+ unit,
116
+ mark,
117
+ anchor
118
+ });
119
+ }
64
120
  function dayPeriod(hour) {
65
121
  if (hour < 6) {
66
122
  return "\u51CC\u6668";
@@ -157,11 +213,28 @@ function renderEveryMinute() {
157
213
  function renderEveryHour() {
158
214
  return "\u6BCF\u5C0F\u65F6";
159
215
  }
216
+ function minuteHourClause(ir) {
217
+ const segments = fieldSegments(ir, "minute");
218
+ if (ir.shapes.minute === "step") {
219
+ return stepClause(stepSegment(ir, "minute"), "\u5206\u949F", "\u5206", "\u6BCF\u5C0F\u65F6");
220
+ }
221
+ return strideFromSegments(segments, "\u5206\u949F", "\u5206", "\u6BCF\u5C0F\u65F6") ?? "\u6BCF\u5C0F\u65F6" + valueList(segments, "\u5206");
222
+ }
160
223
  function renderMinutePast(ir) {
161
- return "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206");
224
+ return minuteHourClause(ir);
225
+ }
226
+ function hourSegmentWords(segment) {
227
+ if (segment.kind === "range") {
228
+ return [hourWord(+segment.bounds[0]) + "\u81F3" + hourWord(+segment.bounds[1])];
229
+ }
230
+ if (segment.kind === "step") {
231
+ return segment.fires.map(hourWord);
232
+ }
233
+ return [hourWord(+segment.value)];
162
234
  }
163
235
  function hourList(ir) {
164
- return joinAnd(hourFires(ir).map(hourWord));
236
+ const words = fieldSegments(ir, "hour").flatMap(hourSegmentWords);
237
+ return joinAnd(words);
165
238
  }
166
239
  function hourFrame(ir) {
167
240
  if (ir.shapes.hour === "range") {
@@ -172,11 +245,13 @@ function hourFrame(ir) {
172
245
  }
173
246
  function renderMinuteFrequency(ir, plan) {
174
247
  const minuteStep = stepSegment(ir, "minute");
175
- const base = minuteStep.startToken === "*" ? cadence(minuteStep.interval, UNITS.minute) : renderMinutePast(ir);
248
+ const base = stepClause(minuteStep, UNITS.minute, "\u5206", "\u6BCF\u5C0F\u65F6");
176
249
  const { hours } = plan;
177
- if (hours.kind === "step") {
178
- const hourStep = stepSegment(ir, "hour");
179
- return hourStep.startToken === "*" ? cadence(hourStep.interval, UNITS.hour) + base : "\u5728" + hourList(ir) + "\uFF0C" + base;
250
+ if (hours.kind === "step" || hours.kind === "during") {
251
+ const hourCad = hourCadencePhrase(ir);
252
+ if (hourCad !== null) {
253
+ return hourCad + (hourCad.indexOf("\u81F3") === -1 ? "" : "\uFF0C") + base;
254
+ }
180
255
  }
181
256
  if (hours.kind === "single" || hours.kind === "window" && hours.from === hours.to) {
182
257
  return "\u5728" + hourWord(hours.from) + "\u81F3" + hours.from + "\u70B9" + hours.last + "\u5206\u4E4B\u95F4\uFF0C" + base;
@@ -198,17 +273,21 @@ function renderMinuteSpanInHour(ir, plan) {
198
273
  }
199
274
  function renderMinutesAcrossHours(ir, plan) {
200
275
  const { form } = plan;
276
+ const hourCad = hourCadencePhrase(ir);
201
277
  if (form === "wildcard") {
202
- return "\u5728" + hourList(ir) + "\uFF0C\u6BCF\u5206\u949F";
278
+ return hourCad === null ? "\u5728" + hourList(ir) + "\uFF0C\u6BCF\u5206\u949F" : hourCad + "\uFF0C\u6BCF\u5206\u949F";
203
279
  }
204
- return hourList(ir) + "\uFF0C\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
280
+ return (hourCad ?? hourList(ir)) + "\uFF0C" + minuteHourClause(ir) + "\uFF0C\u6BCF\u5206\u949F";
205
281
  }
206
282
  function renderMinuteSpanAcrossHourStep(ir, plan) {
207
283
  const hourStep = stepSegment(ir, "hour");
208
284
  const { form } = plan;
209
- const minuteTail = form === "wildcard" ? "\u6BCF\u5206\u949F" : "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
285
+ if (form === "list") {
286
+ return hourCadencePhrase(ir) + "\uFF0C" + renderMinutePast(ir);
287
+ }
288
+ const minuteTail = form === "wildcard" ? "\u6BCF\u5206\u949F" : minuteHourClause(ir) + "\uFF0C\u6BCF\u5206\u949F";
210
289
  if (hourStep.startToken !== "*") {
211
- return form === "wildcard" ? "\u5728" + hourList(ir) + "\uFF0C" + minuteTail : hourList(ir) + "\uFF0C" + minuteTail;
290
+ return hourCadencePhrase(ir) + "\uFF0C" + minuteTail;
212
291
  }
213
292
  if (hourStep.interval === 2 && form === "wildcard") {
214
293
  return "\u5728\u5076\u6570\u5C0F\u65F6\uFF0C" + minuteTail;
@@ -217,15 +296,27 @@ function renderMinuteSpanAcrossHourStep(ir, plan) {
217
296
  return form === "wildcard" ? cad + "\u5185\uFF0C" + minuteTail : cad + "\uFF0C" + minuteTail;
218
297
  }
219
298
  function renderClockTimes(ir, plan, opts) {
299
+ const cad = hourCadenceText(ir);
300
+ if (cad !== null) {
301
+ return cad;
302
+ }
220
303
  const { times } = plan;
221
304
  return joinAnd(times.map((t) => clockTime(t.hour, t.minute, t.second, opts)));
222
305
  }
223
306
  function renderCompactClockTimes(ir, plan) {
224
- const { minute } = plan;
307
+ const cad = hourCadenceText(ir);
308
+ if (cad !== null) {
309
+ return cad;
310
+ }
311
+ const compact = plan;
225
312
  const secs = fieldSegments(ir, "second");
226
313
  const tail = secs.length && ir.pattern.second !== "0" ? "\uFF0C\u7B2C" + valueText(secs) + "\u79D2" : "";
227
- if (minute > 0) {
228
- return "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u5728" + hourList(ir) + tail;
314
+ if (!compact.fold) {
315
+ const hourCad = hourCadencePhrase(ir);
316
+ return hourCad === null ? minuteHourClause(ir) + "\uFF0C\u5728" + hourList(ir) + tail : hourCad + "\uFF0C" + minuteHourClause(ir) + tail;
317
+ }
318
+ if (compact.minute > 0) {
319
+ return minuteHourClause(ir) + "\uFF0C\u5728" + hourList(ir) + tail;
229
320
  }
230
321
  return hourList(ir) + tail;
231
322
  }
@@ -233,7 +324,7 @@ function renderHourRange(ir, plan) {
233
324
  const range = plan;
234
325
  if (range.minuteForm === "lead") {
235
326
  const minuteSegs = fieldSegments(ir, "minute");
236
- const past = minuteSegs.length && ir.pattern.minute !== "0" ? "\u6BCF\u5C0F\u65F6" + valueList(minuteSegs, "\u5206") : "\u6BCF\u5C0F\u65F6";
327
+ const past = minuteSegs.length && ir.pattern.minute !== "0" ? minuteHourClause(ir) : "\u6BCF\u5C0F\u65F6";
237
328
  return "\u5728" + hourWord(range.from) + "\u81F3" + hourWord(range.to) + "\u4E4B\u95F4\uFF0C" + past;
238
329
  }
239
330
  if (range.minuteForm === "range") {
@@ -243,16 +334,68 @@ function renderHourRange(ir, plan) {
243
334
  }
244
335
  function renderHourStep(ir) {
245
336
  const segment = stepSegment(ir, "hour");
246
- if (segment.startToken !== "*") {
247
- return hourList(ir);
248
- }
249
337
  if (segment.fires.length <= 2) {
250
338
  return joinAnd(segment.fires.map(hourWord));
251
339
  }
252
- return cadence(segment.interval, UNITS.hour);
340
+ return hourCadencePhrase(ir);
341
+ }
342
+ function hourStride(ir) {
343
+ const segments = fieldSegments(ir, "hour");
344
+ if (segments.length === 1 && segments[0].kind === "step") {
345
+ const { fires, interval } = segments[0];
346
+ return { interval, start: fires[0], last: fires[fires.length - 1] };
347
+ }
348
+ const values = singleValues(segments);
349
+ return values && arithmeticStep(values);
350
+ }
351
+ function hourCadencePhrase(ir) {
352
+ const stride = hourStride(ir);
353
+ return stride && renderStride({
354
+ ...stride,
355
+ cycle: 24,
356
+ unit: UNITS.hour,
357
+ mark: "\u70B9",
358
+ anchor: ""
359
+ });
360
+ }
361
+ function hourCadence(ir) {
362
+ const stride = hourStride(ir);
363
+ if (!stride) {
364
+ return null;
365
+ }
366
+ const fires = (stride.last - stride.start) / stride.interval + 1;
367
+ if (ir.pattern.second === "0" && fires <= maxClockTimes) {
368
+ return null;
369
+ }
370
+ const prefix = hourCadencePhrase(ir);
371
+ if (prefix.indexOf("\u81F3") !== -1) {
372
+ return null;
373
+ }
374
+ const minute = +ir.pattern.minute;
375
+ const subMinute = ir.pattern.second === "*" || ir.shapes.second === "step";
376
+ if (minute === 0 && subMinute) {
377
+ return stride.interval === 2 && stride.start === 0 ? "\u5728\u5076\u6570\u5C0F\u65F60\u5206" + secondTail(ir) : null;
378
+ }
379
+ if (minute === 0) {
380
+ return prefix + "0\u5206" + secondTail(ir);
381
+ }
382
+ return ir.pattern.second === "0" ? prefix + minute + "\u5206" : prefix + minute + "\u5206" + secondTail(ir);
383
+ }
384
+ function hourCadenceText(ir) {
385
+ if (ir.shapes.minute !== "single") {
386
+ return null;
387
+ }
388
+ if (+ir.pattern.minute === 0 && ir.pattern.second === "0") {
389
+ return hourCadencePhrase(ir);
390
+ }
391
+ return hourCadence(ir);
392
+ }
393
+ function secondTail(ir) {
394
+ const sec = secondClause(ir);
395
+ return sec === "\u6BCF\u79D2" ? "\u7684\u6BCF\u4E00\u79D2" : "\u7684" + sec;
253
396
  }
254
397
  function renderRangeOfMinutes(ir) {
255
- return "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
398
+ return minuteHourClause(ir) + "\uFF0C\u6BCF\u5206\u949F";
256
399
  }
257
400
  function renderStandaloneSeconds(ir) {
258
401
  const segs = fieldSegments(ir, "second");
@@ -260,7 +403,7 @@ function renderStandaloneSeconds(ir) {
260
403
  if (segs.length === 1 && first.kind === "step" && first.startToken === "*") {
261
404
  return cadence(first.interval, UNITS.second);
262
405
  }
263
- return "\u6BCF\u5206\u949F\u7B2C" + valueText(segs) + "\u79D2";
406
+ return strideFromSegments(segs, "\u79D2", "\u79D2", "\u6BCF\u5206\u949F") ?? "\u6BCF\u5206\u949F\u7B2C" + valueText(segs) + "\u79D2";
264
407
  }
265
408
  function renderSecondPastMinute(ir) {
266
409
  return "\u6BCF\u5206\u949F\u7B2C" + valueText(fieldSegments(ir, "second")) + "\u79D2";
@@ -283,7 +426,7 @@ function secondClause(ir) {
283
426
  if (segs.length === 1 && first.kind === "step" && first.startToken === "*") {
284
427
  return cadence(first.interval, UNITS.second);
285
428
  }
286
- return "\u7B2C" + valueText(segs) + "\u79D2";
429
+ return strideFromSegments(segs, "\u79D2", "\u79D2", "\u6BCF\u5206\u949F") ?? "\u7B2C" + valueText(segs) + "\u79D2";
287
430
  }
288
431
  function minuteClause(ir) {
289
432
  if (ir.pattern.minute === "*") {
@@ -294,56 +437,91 @@ function minuteClause(ir) {
294
437
  }
295
438
  return valueList(fieldSegments(ir, "minute"), "\u5206");
296
439
  }
297
- function isHourCadence(ir) {
298
- return ir.shapes.hour === "step" && stepSegment(ir, "hour").startToken === "*";
440
+ function clockRestCarriesSecond(rest) {
441
+ return rest.kind === "clockTimes" && rest.times.some((time) => Boolean(time.second));
299
442
  }
300
443
  function composeSecondsOnHour(ir, plan, opts) {
301
444
  const sec = secondClause(ir);
302
445
  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;
446
+ const composedClock = rest.kind === "clockTimes" || rest.kind === "compactClockTimes";
447
+ if (composedClock) {
448
+ const hourCad = hourCadence(ir);
449
+ if (hourCad !== null) {
450
+ return hourCad;
451
+ }
452
+ }
453
+ if (composedClock && ir.pattern.minute === "0") {
454
+ return composeMinuteZeroClocks(ir, sec);
310
455
  }
311
456
  const restText = render(ir, rest, opts);
312
- if (rest.kind === "clockTimes" || rest.kind === "compactClockTimes") {
313
- if (isDaily(ir)) {
314
- return "\u6BCF\u5929" + restText + sec;
315
- }
457
+ const secTail = clockRestCarriesSecond(rest) ? "" : sec;
458
+ if (composedClock && isDaily(ir)) {
459
+ return "\u6BCF\u5929" + restText + secTail;
316
460
  }
317
461
  if (rest.kind === "singleMinute") {
318
462
  return restText + "\uFF0C" + sec;
319
463
  }
320
- return restText + sec;
464
+ return restText + secTail;
465
+ }
466
+ function composeMinuteZeroClocks(ir, sec) {
467
+ if (hasHourWindow(ir)) {
468
+ return isDaily(ir) ? "\u6BCF\u5929" + hourRangeWindow(ir, sec) : hourRangeWindow(ir, sec);
469
+ }
470
+ const clocks = hourFires(ir).map(function clock(hour) {
471
+ return hour === 12 ? "\u6B63\u5348" : hourWord(hour) + "0\u5206";
472
+ });
473
+ const nested = strideFromSegments(fieldSegments(ir, "second"), "\u79D2", "\u79D2", "");
474
+ const tail = sec === "\u6BCF\u79D2" ? "\u7684\u6BCF\u4E00\u79D2" : "\u7684" + (nested ?? sec);
475
+ const core = joinAnd(clocks) + tail;
476
+ return isDaily(ir) ? "\u6BCF\u5929" + core : core;
477
+ }
478
+ function hasHourWindow(ir) {
479
+ return fieldSegments(ir, "hour").some(function range(segment) {
480
+ return segment.kind === "range";
481
+ });
482
+ }
483
+ function hourRangeWindow(ir, sec) {
484
+ const span = hourList(ir);
485
+ if (ir.pattern.second === "*" || ir.shapes.second === "step") {
486
+ return span + "0\u5206" + (sec === "\u6BCF\u79D2" ? "\u7684\u6BCF\u4E00\u79D2" : "\u7684" + sec);
487
+ }
488
+ return span + "\uFF0C" + sec;
321
489
  }
322
490
  function composeSecondsCadence(ir) {
323
491
  const sec = secondClause(ir);
324
492
  const tail = minuteClause(ir) + sec;
325
- if (isHourCadence(ir)) {
326
- return cadence(stepSegment(ir, "hour").interval, UNITS.hour) + "\u7684" + tail;
493
+ const hourCad = hourCadencePhrase(ir);
494
+ if (hourCad !== null) {
495
+ return hourCad + (hourCad.indexOf("\u81F3") === -1 ? "\u7684" : "\uFF0C") + tail;
327
496
  }
328
497
  if (ir.shapes.hour === "single") {
329
498
  return hourWord(hourFires(ir)[0]) + "\u7684" + tail;
330
499
  }
331
500
  if (ir.shapes.hour === "wildcard") {
501
+ if (ir.shapes.minute === "step" && sec === "\u6BCF\u79D2") {
502
+ const minuteStep = stepSegment(ir, "minute");
503
+ if (minuteStep.startToken === "*" && minuteStep.interval === 2) {
504
+ return "\u6BCF\u5076\u6570\u5206\u949F\u7684\u6BCF\u4E00\u79D2";
505
+ }
506
+ }
332
507
  return sec + "\uFF0C" + minuteClause(ir);
333
508
  }
334
509
  return hourFrame(ir) + tail;
335
510
  }
336
511
  function composeSecondsListed(ir) {
337
512
  const sec = secondClause(ir);
338
- const minutes = "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206");
513
+ const minutes = minuteHourClause(ir);
339
514
  if (ir.shapes.hour === "single" && sec === "\u6BCF\u79D2") {
340
- return hourWord(hourFires(ir)[0]) + valueList(fieldSegments(ir, "minute"), "\u5206") + "\u7684\u6BCF\u4E00\u79D2";
515
+ const minuteSegs = fieldSegments(ir, "minute");
516
+ const minuteCad = strideFromSegments(minuteSegs, "\u5206\u949F", "\u5206", "") ?? valueList(minuteSegs, "\u5206");
517
+ return hourWord(hourFires(ir)[0]) + minuteCad + "\u7684\u6BCF\u4E00\u79D2";
341
518
  }
342
519
  if (ir.shapes.hour === "wildcard") {
343
520
  return minutes + "\uFF0C" + sec;
344
521
  }
345
- if (isHourCadence(ir)) {
346
- return cadence(stepSegment(ir, "hour").interval, UNITS.hour) + "\uFF0C" + minutes + "\uFF0C" + sec;
522
+ const hourCad = hourCadencePhrase(ir);
523
+ if (hourCad !== null) {
524
+ return hourCad + "\uFF0C" + minutes + "\uFF0C" + sec;
347
525
  }
348
526
  return hourFrame(ir) + minutes + "\uFF0C" + sec;
349
527
  }
@@ -554,11 +732,16 @@ function composeCadence(ir, core) {
554
732
  function composeWindow(ir, core) {
555
733
  return qualifier(ir) + core;
556
734
  }
735
+ function hourCadenceApplies(ir) {
736
+ return hourCadenceText(ir) !== null;
737
+ }
557
738
  function describe(ir, opts) {
558
739
  const { kind } = ir.plan;
559
740
  const core = render(ir, ir.plan, opts);
560
741
  let composed = core;
561
- if (kind === "clockTimes" || kind === "compactClockTimes" && ir.pattern.minute === "0") {
742
+ if (hourCadenceApplies(ir)) {
743
+ composed = composeCadence(ir, core);
744
+ } else if (kind === "clockTimes" || kind === "compactClockTimes" && ir.pattern.minute === "0") {
562
745
  composed = composePoint(ir, core);
563
746
  } else if (kind === "hourStep" || kind === "minuteFrequency" || kind === "minuteSpanAcrossHourStep" || kind === "compactClockTimes") {
564
747
  composed = composeCadence(ir, core);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronli5",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Cron Like I'm Five: A Cron to English Utility",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,6 +60,7 @@
60
60
  "scripts": {
61
61
  "build": "node scripts/build.mjs && npm run types",
62
62
  "docs": "node --import tsx scripts/docs.mjs",
63
+ "conciseness": "node --import tsx tooling/scripts/conciseness.mjs",
63
64
  "fuzz": "node --import tsx scripts/fuzz-lang.mjs",
64
65
  "lint": "eslint src test cli.js eslint.config.js scripts tooling/scripts",
65
66
  "metamorphic": "node --import tsx tooling/scripts/metamorphic.mjs",
@@ -69,7 +70,7 @@
69
70
  "test:types": "npm run types && tsd",
70
71
  "typecheck": "tsc -p tsconfig.json",
71
72
  "coverage": "c8 mocha",
72
- "verify": "npm run lint && npm run typecheck && npm run test:types && npm test && npm run coverage && npm run docs -- --check && npm run build",
73
+ "verify": "npm run lint && npm run typecheck && npm run test:types && npm test && npm run coverage && npm run conciseness && npm run docs -- --check && npm run build",
73
74
  "prepare": "node scripts/install-hooks.mjs",
74
75
  "prepublishOnly": "npm run lint && npm run typecheck && npm run test:types && npm run build && npm test"
75
76
  },
@@ -376,6 +376,13 @@ function planMinuteUnderHourStep(
376
376
  return {form: 'range', kind: 'minuteSpanAcrossHourStep'};
377
377
  }
378
378
 
379
+ // A minute list under a clean stride keeps the cadence too, so the hour
380
+ // reads the same whatever the minute's shape. An unclean stride falls
381
+ // through to compactClockTimes and enumerates its hours.
382
+ if (shapes.minute === 'list' && cleanHourStride(pattern.hour)) {
383
+ return {form: 'list', kind: 'minuteSpanAcrossHourStep'};
384
+ }
385
+
379
386
  return null;
380
387
  }
381
388
 
package/src/core/ir.ts CHANGED
@@ -71,7 +71,7 @@ export type PlanNode =
71
71
  form: 'wildcard' | 'range' | 'list';
72
72
  times: HourTimesPlan;
73
73
  }
74
- | {kind: 'minuteSpanAcrossHourStep'; form: 'wildcard' | 'range'}
74
+ | {kind: 'minuteSpanAcrossHourStep'; form: 'wildcard' | 'range' | 'list'}
75
75
  | {kind: 'everyHour'}
76
76
  | {
77
77
  kind: 'hourRange';
package/src/core/util.ts CHANGED
@@ -16,6 +16,34 @@ function isNonNegativeInteger(value: string): boolean {
16
16
  return digits.test(value);
17
17
  }
18
18
 
19
+ // Recognize an arithmetic progression in a sorted, distinct numeric set: a
20
+ // run of length >= 5 whose consecutive gaps are all equal and >= 2. Returns
21
+ // its {start, interval, last}; null for anything shorter, with a gap of one
22
+ // (a plain run, which reads as a range), or irregular. Output-neutral and
23
+ // language-agnostic: renderers use it to speak a bounded/offset step cadence
24
+ // ("every N from M [through K]") instead of enumerating the fires. The set is
25
+ // the field's full value list, which the core has already sorted and deduped.
26
+ function arithmeticStep(values: number[]):
27
+ {start: number; interval: number; last: number} | null {
28
+ if (values.length < 5) {
29
+ return null;
30
+ }
31
+
32
+ const interval = values[1] - values[0];
33
+
34
+ if (interval < 2) {
35
+ return null;
36
+ }
37
+
38
+ for (let i = 2; i < values.length; i += 1) {
39
+ if (values[i] - values[i - 1] !== interval) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ return {start: values[0], interval, last: values[values.length - 1]};
45
+ }
46
+
19
47
  // Resolve a numeric or named field token (e.g. '5' or 'FRI') to its number.
20
48
  function toFieldNumber(
21
49
  token: string,
@@ -25,4 +53,6 @@ function toFieldNumber(
25
53
  // weekday) reach here. They always have an associated `numberMap`.
26
54
  return isNonNegativeInteger(token) ? +token : numberMap![token.toUpperCase()];
27
55
  }
28
- export {includes, isNonNegativeInteger, toFieldNumber, unique};
56
+ export {
57
+ arithmeticStep, includes, isNonNegativeInteger, toFieldNumber, unique
58
+ };