cronli5 0.1.2 → 0.1.5

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.
@@ -4,8 +4,8 @@
4
4
  // big-endian dates, 每 for recurrence, 24-hour clock with 凌晨0点/正午 anchors,
5
5
  // day periods under `ampm`. The style contract is src/lang/zh/notes.md.
6
6
 
7
- import {toFieldNumber} from '../../core/util.js';
8
- import {monthNumbers, weekdayNumbers} from '../../core/specs.js';
7
+ import {arithmeticStep, toFieldNumber} from '../../core/util.js';
8
+ import {maxClockTimes, monthNumbers, weekdayNumbers} from '../../core/specs.js';
9
9
  import type {Cronli5Options} from '../../types.js';
10
10
  import type {
11
11
  Field, IR, Language, NormalizedOptions, PlanNode, Segment
@@ -43,6 +43,108 @@ function cadence(interval: number, unit: string): string {
43
43
  return interval === 1 ? '每' + unit : '每' + interval + unit;
44
44
  }
45
45
 
46
+ // A step cadence to phrase over a `cycle`-long field (60 for minute/second),
47
+ // running from `start` to `last`. `unit` is the cadence noun ("分钟"/"秒"),
48
+ // `mark` the bound measure word ("分"/"秒"), `anchor` the larger frame
49
+ // ("每小时"/"每分钟").
50
+ interface Stride {
51
+ interval: number;
52
+ start: number;
53
+ last: number;
54
+ cycle: number;
55
+ unit: string;
56
+ mark: string;
57
+ anchor: string;
58
+ }
59
+
60
+ // Speak a step cadence over a `cycle`-long field. A clean stride from the top
61
+ // of the cycle is the bare cadence ("每2分钟"); a uniform offset (start within
62
+ // the first interval, the interval still dividing the cycle) names only its
63
+ // start, since it wraps cleanly with no distinct endpoint ("每小时从5分起每6分
64
+ // 钟"); a non-uniform stride (start >= interval, or an interval that does not
65
+ // divide the cycle) pins both endpoints so the bounded, non-wrapping set reads
66
+ // unambiguously ("每小时从3分起每2分钟,至59分"). This is the one phrasing for
67
+ // every step the renderer speaks, whether the core kept it a step shape (a
68
+ // clean cadence) or enumerated it to a fire list (an offset/uneven set the
69
+ // list path recognizes as a progression).
70
+ function renderStride(stride: Stride): string {
71
+ const {interval, start, last, cycle, unit, mark, anchor} = stride;
72
+ const tiles = cycle % interval === 0;
73
+
74
+ if (start === 0 && tiles) {
75
+ return cadence(interval, unit);
76
+ }
77
+
78
+ const lead = anchor + '从' + start + mark + '起' + cadence(interval, unit);
79
+
80
+ return start < interval && tiles ? lead : lead + ',至' + last + mark;
81
+ }
82
+
83
+ // The sorted numeric values a field's segments cover, or null if any segment
84
+ // is not a discrete single (a range or sub-step is not a plain fire list).
85
+ function singleValues(segments: Segment[]): number[] | null {
86
+ const values: number[] = [];
87
+
88
+ for (const segment of segments) {
89
+ if (segment.kind !== 'single') {
90
+ return null;
91
+ }
92
+
93
+ values.push(+segment.value);
94
+ }
95
+
96
+ return values;
97
+ }
98
+
99
+ // Speak a minute/second field's enumerated fires as a step cadence when they
100
+ // form an arithmetic progression long enough to beat the list (the core
101
+ // enumerates an offset/uneven step to this fire list; the IR is unchanged, so
102
+ // the renderer recognizes the progression). Returns null for a non-progression
103
+ // or a too-short list, leaving the caller to enumerate.
104
+ function strideFromSegments(
105
+ segments: Segment[],
106
+ unit: string,
107
+ mark: string,
108
+ anchor: string
109
+ ): string | null {
110
+ const values = singleValues(segments);
111
+ const step = values && arithmeticStep(values);
112
+
113
+ return step ?
114
+ renderStride({...step, cycle: 60, unit, mark, anchor}) :
115
+ null;
116
+ }
117
+
118
+ // A step *shape* segment as its cadence ("每小时从5分起每6分钟"). A bounded
119
+ // sub-range step (`a-b/n`) is not a whole-cycle stride, so it lists its fires;
120
+ // a short offset cadence (three fires or fewer) also lists, since the list is
121
+ // no longer than the cadence. Everything else routes through `renderStride`.
122
+ // The uneven whole-cycle step never reaches here as a step shape — the core
123
+ // enumerates it to a fire list, which the list path recognizes instead.
124
+ function stepClause(
125
+ segment: StepSegment,
126
+ unit: string,
127
+ mark: string,
128
+ anchor: string
129
+ ): string {
130
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
131
+ const short = start !== 0 && segment.fires.length <= 3;
132
+
133
+ if (segment.startToken.indexOf('-') !== -1 || short) {
134
+ return anchor + segment.fires.join('、') + mark;
135
+ }
136
+
137
+ return renderStride({
138
+ interval: segment.interval,
139
+ start,
140
+ last: segment.fires[segment.fires.length - 1],
141
+ cycle: 60,
142
+ unit,
143
+ mark,
144
+ anchor
145
+ });
146
+ }
147
+
46
148
  // The day period a 12-hour clock prepends under `ampm` (notes.md boundaries).
47
149
  function dayPeriod(hour: number): string {
48
150
  if (hour < 6) {
@@ -193,8 +295,23 @@ function renderEveryHour(): string {
193
295
  }
194
296
 
195
297
  // A minute anchored to the hour: "每小时1分", "每小时5分和30分", "每小时0至30分".
298
+ // A regular step reads as a stride cadence ("每小时从3分起每2分钟,至59分"),
299
+ // whether the core kept it a step shape (a uniform offset divides 60) or
300
+ // enumerated it to a fire list (an offset/uneven set) — both route through the
301
+ // stride; a short or irregular set keeps the enumerated "每小时…分" list.
302
+ function minuteHourClause(ir: IR): string {
303
+ const segments = fieldSegments(ir, 'minute');
304
+
305
+ if (ir.shapes.minute === 'step') {
306
+ return stepClause(stepSegment(ir, 'minute'), '分钟', '分', '每小时');
307
+ }
308
+
309
+ return strideFromSegments(segments, '分钟', '分', '每小时') ??
310
+ '每小时' + valueList(segments, '分');
311
+ }
312
+
196
313
  function renderMinutePast(ir: IR): string {
197
- return '每小时' + valueList(fieldSegments(ir, 'minute'), '分');
314
+ return minuteHourClause(ir);
198
315
  }
199
316
 
200
317
  // The hour list as clock words: "9点、11点和13点".
@@ -218,15 +335,19 @@ function hourFrame(ir: IR): string {
218
335
  // A repeating minute step, optionally confined to active hours.
219
336
  function renderMinuteFrequency(ir: IR, plan: PlanNode): string {
220
337
  const minuteStep = stepSegment(ir, 'minute');
221
- // A "每N分钟" cadence is only faithful from the top of the hour; an offset
222
- // step (5/6 fires at :05,:11,…) enumerates its fires instead.
223
- const base = minuteStep.startToken === '*' ?
224
- cadence(minuteStep.interval, UNITS.minute) :
225
- renderMinutePast(ir);
338
+ // A clean stride is the bare "每N分钟" cadence; an offset step keeps its start
339
+ // ("每小时从5分起每6分钟"). A short offset cadence still lists its fires.
340
+ const base = stepClause(minuteStep, UNITS.minute, '分', '每小时');
226
341
  const {hours} = plan as Extract<PlanNode, {kind: 'minuteFrequency'}>;
227
342
 
228
343
  if (hours.kind === 'step') {
229
- return cadence(stepSegment(ir, 'hour').interval, UNITS.hour) + base;
344
+ const hourStep = stepSegment(ir, 'hour');
345
+
346
+ // "每N小时" is only faithful from midnight; an offset step (2/6 fires at
347
+ // 2,8,14,20) enumerates its hours instead.
348
+ return hourStep.startToken === '*' ?
349
+ cadence(hourStep.interval, UNITS.hour) + base :
350
+ '在' + hourList(ir) + ',' + base;
230
351
  }
231
352
 
232
353
  if (hours.kind === 'single' ||
@@ -247,10 +368,16 @@ function renderMinuteFrequency(ir: IR, plan: PlanNode): string {
247
368
  return base;
248
369
  }
249
370
 
250
- // A minute span within a single hour: "在9点至9点58分之间,每分钟".
371
+ // A minute span within a single hour. A wildcard minute reads as that hour
372
+ // itself — "凌晨0点的每一分钟" — not a synthesized "在H点至H点59分之间" range the
373
+ // source never stated; a partial minute span keeps the named range.
251
374
  function renderMinuteSpanInHour(ir: IR, plan: PlanNode): string {
252
375
  const span = plan as Extract<PlanNode, {kind: 'minuteSpanInHour'}>;
253
376
 
377
+ if (ir.pattern.minute === '*') {
378
+ return hourWord(span.hour) + '的每一分钟';
379
+ }
380
+
254
381
  return '在' + hourWord(span.hour) + '至' + span.hour + '点' +
255
382
  span.span[1] + '分之间,每分钟';
256
383
  }
@@ -264,26 +391,58 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanNode): string {
264
391
  return '在' + hourList(ir) + ',每分钟';
265
392
  }
266
393
 
267
- return hourList(ir) + ',每小时' +
268
- valueList(fieldSegments(ir, 'minute'), '分') + ',每分钟';
394
+ return hourList(ir) + '' + minuteHourClause(ir) + ',每分钟';
269
395
  }
270
396
 
271
397
  // A minute clause across a stepped hour field. A wildcard minute reads "每2小时
272
398
  // 内,每分钟"; a ranged minute names it: "每2小时,每小时0至30分,每分钟".
273
399
  function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
274
- const cad = cadence(stepSegment(ir, 'hour').interval, UNITS.hour);
400
+ const hourStep = stepSegment(ir, 'hour');
275
401
  const {form} = plan as Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>;
276
402
 
277
- if (form === 'wildcard') {
278
- return cad + '内,每分钟';
403
+ // A minute list enumerates its hours, with no "from M" idiom to lean on:
404
+ // "每小时5分和30分,在1点、3点…".
405
+ if (form === 'list') {
406
+ return renderMinutePast(ir) + ',在' + hourList(ir);
279
407
  }
280
408
 
281
- return cad + ',每小时' + valueList(fieldSegments(ir, 'minute'), '分') +
282
- ',每分钟';
409
+ const minuteTail = form === 'wildcard' ?
410
+ '每分钟' :
411
+ minuteHourClause(ir) + ',每分钟';
412
+
413
+ // An offset stride (2/6 fires at 2,8,14,20) enumerates its hours like a
414
+ // discrete list; "每N小时" is faithful only from midnight.
415
+ if (hourStep.startToken !== '*') {
416
+ return form === 'wildcard' ?
417
+ '在' + hourList(ir) + ',' + minuteTail :
418
+ hourList(ir) + ',' + minuteTail;
419
+ }
420
+
421
+ // A step-2 hour from midnight IS exactly the even hours; name them so, rather
422
+ // than the vague "每2小时内" that reads as an interval. Other strides keep it.
423
+ if (hourStep.interval === 2 && form === 'wildcard') {
424
+ return '在偶数小时,' + minuteTail;
425
+ }
426
+
427
+ const cad = cadence(hourStep.interval, UNITS.hour);
428
+
429
+ return form === 'wildcard' ?
430
+ cad + '内,' + minuteTail :
431
+ cad + ',' + minuteTail;
283
432
  }
284
433
 
285
434
  // Discrete clock times: "9点", "9点和17点".
286
435
  function renderClockTimes(ir: IR, plan: PlanNode, opts: Opts): string {
436
+ // An hour step (or arithmetic-progression hour list) under a single pinned
437
+ // minute reads as a cadence rather than a cross-product of clock times.
438
+ if (ir.shapes.minute === 'single') {
439
+ const cad = hourCadence(ir);
440
+
441
+ if (cad !== null) {
442
+ return cad;
443
+ }
444
+ }
445
+
287
446
  const {times} = plan as Extract<PlanNode, {kind: 'clockTimes'}>;
288
447
 
289
448
  return joinAnd(times.map((t) => clockTime(t.hour, t.minute, t.second, opts)));
@@ -292,14 +451,24 @@ function renderClockTimes(ir: IR, plan: PlanNode, opts: Opts): string {
292
451
  // Compact clock times past the cap: the hour list (the minute is folded in),
293
452
  // with a fixed second appended ("…,第30秒").
294
453
  function renderCompactClockTimes(ir: IR, plan: PlanNode): string {
454
+ // An hour step (or arithmetic-progression hour list) under the single pinned
455
+ // minute reads as a cadence, not a wall of clock times. (Returns null for an
456
+ // irregular list or a range, which keep enumerating below.)
457
+ if (ir.shapes.minute === 'single') {
458
+ const cad = hourCadence(ir);
459
+
460
+ if (cad !== null) {
461
+ return cad;
462
+ }
463
+ }
464
+
295
465
  const {minute} = plan as Extract<PlanNode, {kind: 'compactClockTimes'}>;
296
466
  const secs = fieldSegments(ir, 'second');
297
467
  const tail = secs.length && ir.pattern.second !== '0' ?
298
468
  ',第' + valueText(secs) + '秒' : '';
299
469
 
300
470
  if (minute > 0) {
301
- return '每小时' + valueList(fieldSegments(ir, 'minute'), '分') +
302
- ',在' + hourList(ir) + tail;
471
+ return minuteHourClause(ir) + ',在' + hourList(ir) + tail;
303
472
  }
304
473
 
305
474
  return hourList(ir) + tail;
@@ -312,7 +481,7 @@ function renderHourRange(ir: IR, plan: PlanNode): string {
312
481
  if (range.minuteForm === 'lead') {
313
482
  const minuteSegs = fieldSegments(ir, 'minute');
314
483
  const past = minuteSegs.length && ir.pattern.minute !== '0' ?
315
- '每小时' + valueList(minuteSegs, '分') : '每小时';
484
+ minuteHourClause(ir) : '每小时';
316
485
 
317
486
  return '在' + hourWord(range.from) + '至' + hourWord(range.to) +
318
487
  '之间,' + past;
@@ -346,9 +515,101 @@ function renderHourStep(ir: IR): string {
346
515
  return cadence(segment.interval, UNITS.hour);
347
516
  }
348
517
 
518
+ // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
519
+
520
+ // The hour field's stride, or null when the hour is not a cadence: a clean
521
+ // step segment from midnight yields its {interval, last}; an all-single hour
522
+ // list yields one only when its values form a long-enough arithmetic
523
+ // progression from midnight (so an irregular list like 9,17 keeps
524
+ // enumerating). Chinese has no "每N小时从X起" offset-hour idiom, so an offset or
525
+ // bounded stride (start > 0, or an interval not dividing 24) returns null and
526
+ // the caller enumerates its clock words, as before. The IR is unchanged.
527
+ function hourStride(ir: IR): {interval: number; last: number} | null {
528
+ const segments = fieldSegments(ir, 'hour');
529
+
530
+ if (segments.length === 1 && segments[0].kind === 'step') {
531
+ const segment = segments[0];
532
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
533
+
534
+ if (start !== 0 || 24 % segment.interval !== 0) {
535
+ return null;
536
+ }
537
+
538
+ return {interval: segment.interval,
539
+ last: segment.fires[segment.fires.length - 1]};
540
+ }
541
+
542
+ const values = singleValues(segments);
543
+ const step = values && arithmeticStep(values);
544
+
545
+ if (!step || step.start !== 0 || 24 % step.interval !== 0) {
546
+ return null;
547
+ }
548
+
549
+ return {interval: step.interval, last: step.last};
550
+ }
551
+
552
+ // Render an hour step (or arithmetic-progression hour list) under a single
553
+ // pinned minute and a second as a cadence — "每N小时" plus the minute/second —
554
+ // instead of cross-multiplying the hours into a wall of clock times. Returns
555
+ // null when the hour is not a clean stride from midnight, or when the
556
+ // cross-product is short enough that enumeration is no longer than the cadence:
557
+ // a meaningful second makes every clock time carry a second, so any stride is
558
+ // worth compacting; otherwise the stride must exceed the clock-time cap, the
559
+ // same point at which the core itself stops enumerating. Renderer-only; the IR
560
+ // is unchanged.
561
+ function hourCadence(ir: IR): string | null {
562
+ const stride = hourStride(ir);
563
+
564
+ if (!stride) {
565
+ return null;
566
+ }
567
+
568
+ const fires = stride.last / stride.interval + 1;
569
+
570
+ if (ir.pattern.second === '0' && fires <= maxClockTimes) {
571
+ return null;
572
+ }
573
+
574
+ const prefix = cadence(stride.interval, UNITS.hour);
575
+ const minute = +ir.pattern.minute;
576
+ const subMinute = ir.pattern.second === '*' || ir.shapes.second === 'step';
577
+
578
+ // A wildcard or sub-minute step second confined to minute 0 of a clean
579
+ // stride is a confinement, not a juxtaposed cadence. Reuse the even-hours
580
+ // idiom ("在偶数小时0分的每一秒") so the form does NOT contain the bare "每2小时"
581
+ // and can never be misread as the absorbing hour cadence (the same reason en
582
+ // says "for one minute during every other hour", not "every two hours"). The
583
+ // idiom exists only for the even-hour stride; another clean stride keeps
584
+ // enumerating (return null) rather than coin a misleading "每N小时…" form.
585
+ if (minute === 0 && subMinute) {
586
+ return stride.interval === 2 ? '在偶数小时0分' + secondTail(ir) : null;
587
+ }
588
+
589
+ // A pinned minute 0 folds into the cadence with the explicit "0分" so the
590
+ // confinement stays visible ("每2小时0分的第30秒"); a non-zero minute is a real
591
+ // clock minute named after the cadence ("每2小时5分的每一秒"). A plain :00
592
+ // second adds nothing.
593
+ if (minute === 0) {
594
+ return prefix + '0分' + secondTail(ir);
595
+ }
596
+
597
+ return ir.pattern.second === '0' ?
598
+ prefix + minute + '分' :
599
+ prefix + minute + '分' + secondTail(ir);
600
+ }
601
+
602
+ // The fused second tail for an hour cadence: "的每一秒" for a wildcard second,
603
+ // else "的" + the second's own clause ("的第30秒", "的每15秒").
604
+ function secondTail(ir: IR): string {
605
+ const sec = secondClause(ir);
606
+
607
+ return sec === '每秒' ? '的每一秒' : '的' + sec;
608
+ }
609
+
349
610
  // A continuous minute range fires every minute within it: "每小时0至30分,每分钟".
350
611
  function renderRangeOfMinutes(ir: IR): string {
351
- return '每小时' + valueList(fieldSegments(ir, 'minute'), '分') + ',每分钟';
612
+ return minuteHourClause(ir) + ',每分钟';
352
613
  }
353
614
 
354
615
  // A standalone second field: "每7秒" (step cadence) or "每分钟第4、17、42秒".
@@ -360,7 +621,8 @@ function renderStandaloneSeconds(ir: IR): string {
360
621
  return cadence(first.interval, UNITS.second);
361
622
  }
362
623
 
363
- return '每分钟第' + valueText(segs) + '秒';
624
+ return strideFromSegments(segs, '秒', '秒', '每分钟') ??
625
+ '每分钟第' + valueText(segs) + '秒';
364
626
  }
365
627
 
366
628
  // A second anchored to the minute: "每分钟第1秒", "每分钟第4、17、42秒".
@@ -395,7 +657,10 @@ function secondClause(ir: IR): string {
395
657
  return cadence(first.interval, UNITS.second);
396
658
  }
397
659
 
398
- return '第' + valueText(segs) + '秒';
660
+ // An offset/uneven step the core enumerated to this list reads as a stride
661
+ // cadence when the fires form a long-enough progression.
662
+ return strideFromSegments(segs, '秒', '秒', '每分钟') ??
663
+ '第' + valueText(segs) + '秒';
399
664
  }
400
665
 
401
666
  // The minute clause for a composed (seconds) schedule.
@@ -422,11 +687,34 @@ function isHourCadence(ir: IR): boolean {
422
687
  function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
423
688
  const sec = secondClause(ir);
424
689
  const {rest} = plan as Extract<PlanNode, {kind: 'composeSeconds'}>;
690
+ const composedClock =
691
+ rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes';
692
+
693
+ // An hour step (or arithmetic-progression hour list) under a single pinned
694
+ // minute reads as a cadence, not a wall of clock times ("每2小时0分的第30秒").
695
+ // hourCadence folds in the second itself, so it is returned whole, never the
696
+ // rest-plus-second fall-through that would double the second clause. The
697
+ // cadence is sub-daily (no 每天); a qualifier is added by describe().
698
+ if (composedClock) {
699
+ const hourCad = hourCadence(ir);
700
+
701
+ if (hourCad !== null) {
702
+ return hourCad;
703
+ }
704
+ }
705
+
706
+ // The minute is pinned to 0 under specific hours that did not compact to a
707
+ // cadence: fuse the seconds with each explicit clock minute.
708
+ if (composedClock && ir.pattern.minute === '0') {
709
+ return composeMinuteZeroClocks(ir, sec);
710
+ }
711
+
425
712
  const restText = render(ir, rest, opts);
426
713
 
427
- if ((rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') &&
428
- isDaily(ir)) {
429
- return '每天' + restText + sec;
714
+ if (rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') {
715
+ if (isDaily(ir)) {
716
+ return '每天' + restText + sec;
717
+ }
430
718
  }
431
719
 
432
720
  // A stated minute (e.g. minute 0 under a sub-minute second) takes the same
@@ -438,6 +726,27 @@ function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
438
726
  return restText + sec;
439
727
  }
440
728
 
729
+ // A minute pinned to 0 under specific clock hours (not a compacted cadence): a
730
+ // bare clock word ("9点") would hide the :00 and leave the second dangling
731
+ // ("…9点每秒"), reading as the whole hour. Fuse the seconds with the explicit
732
+ // clock minute ("9点0分的每一秒"), so the one-minute confinement (60 fires in
733
+ // :00, not 3,600 across the hour) stays visible. The daily frame leads with
734
+ // 每天; a weekday or date qualifier is added by describe().
735
+ function composeMinuteZeroClocks(ir: IR, sec: string): string {
736
+ const clocks = hourFires(ir).map(function clock(hour): string {
737
+ // Noon's word (正午) already pins 12:00, so the "0分" is redundant for it;
738
+ // midnight (凌晨0点) and other hours still need it to pin the minute.
739
+ return hour === 12 ? '正午' : hourWord(hour) + '0分';
740
+ });
741
+ // A pinned minute makes the seconds' own "每分钟" anchor misleading (it is a
742
+ // single minute, not every minute), so the stride here drops it.
743
+ const nested = strideFromSegments(fieldSegments(ir, 'second'), '秒', '秒', '');
744
+ const tail = sec === '每秒' ? '的每一秒' : '的' + (nested ?? sec);
745
+ const core = joinAnd(clocks) + tail;
746
+
747
+ return isDaily(ir) ? '每天' + core : core;
748
+ }
749
+
441
750
  // Wildcard or stepped minute: hang the "每分钟/每N分钟每秒" tail off the hour.
442
751
  function composeSecondsCadence(ir: IR): string {
443
752
  const sec = secondClause(ir);
@@ -452,6 +761,18 @@ function composeSecondsCadence(ir: IR): string {
452
761
  }
453
762
 
454
763
  if (ir.shapes.hour === 'wildcard') {
764
+ // "每秒,每2分钟" juxtaposes two cadences that read as contradictory. A
765
+ // step-2 minute from the top of the hour IS exactly the even minutes; bind
766
+ // the every-second cadence to them ("每偶数分钟的每一秒") rather than listing
767
+ // the two side by side. Other strides keep the juxtaposed form.
768
+ if (ir.shapes.minute === 'step' && sec === '每秒') {
769
+ const minuteStep = stepSegment(ir, 'minute');
770
+
771
+ if (minuteStep.startToken === '*' && minuteStep.interval === 2) {
772
+ return '每偶数分钟的每一秒';
773
+ }
774
+ }
775
+
455
776
  return sec + ',' + minuteClause(ir);
456
777
  }
457
778
 
@@ -464,7 +785,21 @@ function composeSecondsCadence(ir: IR): string {
464
785
  // last fire onto the window end ("…17点30分") and reading as a continuous span.
465
786
  function composeSecondsListed(ir: IR): string {
466
787
  const sec = secondClause(ir);
467
- const minutes = '每小时' + valueList(fieldSegments(ir, 'minute'), '分');
788
+ const minutes = minuteHourClause(ir);
789
+
790
+ // A single restricted hour with an every-second cadence fuses the clock time
791
+ // with its minutes — "凌晨0点5、20、35、50分的每一秒" — rather than the "每小时"
792
+ // that falsely implies every hour. A non-wildcard second keeps the list form.
793
+ // A non-uniform minute step the core enumerated to a fire list reads as its
794
+ // stride cadence ("凌晨0点从3分起每2分钟,至59分的每一秒"); the hour fuses, so the
795
+ // stride drops its "每小时" anchor. A short or irregular set keeps the list.
796
+ if (ir.shapes.hour === 'single' && sec === '每秒') {
797
+ const minuteSegs = fieldSegments(ir, 'minute');
798
+ const minuteCad = strideFromSegments(minuteSegs, '分钟', '分', '') ??
799
+ valueList(minuteSegs, '分');
800
+
801
+ return hourWord(hourFires(ir)[0]) + minuteCad + '的每一秒';
802
+ }
468
803
 
469
804
  if (ir.shapes.hour === 'wildcard') {
470
805
  return minutes + ',' + sec;
@@ -479,12 +814,28 @@ function composeSecondsListed(ir: IR): string {
479
814
  }
480
815
 
481
816
  // Seconds composed with the minute/hour structure, dispatched on the minute.
817
+ // A single minute over a composed clock-time rest (the core already joined the
818
+ // lone hour and minute into "N点M分") keeps that composition, attaching the
819
+ // second to it rather than splitting the minute back out into the "每小时N分"
820
+ // list path; a minute list stays on that list path so each fire is named.
482
821
  function renderComposeSeconds(ir: IR, plan: PlanNode, opts: Opts): string {
483
- if (ir.pattern.minute === '0') {
822
+ const {rest} = plan as Extract<PlanNode, {kind: 'composeSeconds'}>;
823
+ const composedClock =
824
+ rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes';
825
+
826
+ if (ir.pattern.minute === '0' ||
827
+ composedClock && ir.shapes.minute === 'single') {
484
828
  return composeSecondsOnHour(ir, plan, opts);
485
829
  }
486
830
 
487
- if (ir.pattern.minute === '*' || ir.shapes.minute === 'step') {
831
+ // "每N分钟" is faithful only for a wildcard or top-of-hour step; an offset
832
+ // step (5/15 fires at :05,:20,…) takes the enumerated list path so its start
833
+ // is named, never dropped.
834
+ const minuteCadence = ir.pattern.minute === '*' ||
835
+ ir.shapes.minute === 'step' &&
836
+ stepSegment(ir, 'minute').startToken === '*';
837
+
838
+ if (minuteCadence) {
488
839
  return composeSecondsCadence(ir);
489
840
  }
490
841
 
@@ -790,14 +1141,26 @@ function composeWindow(ir: IR, core: string): string {
790
1141
  return qualifier(ir) + core;
791
1142
  }
792
1143
 
1144
+ // Whether an hour cadence applies — a single pinned minute over a clean hour
1145
+ // stride — so the clock-point plans take the cadence frame, not the daily one.
1146
+ function hourCadenceApplies(ir: IR): boolean {
1147
+ return ir.shapes.minute === 'single' && hourCadence(ir) !== null;
1148
+ }
1149
+
793
1150
  function describe(ir: IR, opts: Opts): string {
794
1151
  const {kind} = ir.plan;
795
1152
  const core = render(ir, ir.plan, opts);
796
1153
  let composed = core;
797
1154
 
1155
+ // An hour step (or arithmetic-progression hour list) under a single pinned
1156
+ // minute is a sub-daily cadence, not a daily clock point — it takes the
1157
+ // cadence frame (no 每天), like the bare "每2小时" form.
1158
+ if (hourCadenceApplies(ir)) {
1159
+ composed = composeCadence(ir, core);
1160
+ }
798
1161
  // A compact clock list with a minute past the hour ("每小时5分…") reads as a
799
1162
  // cadence, not a daily clock point — no 每天.
800
- if (kind === 'clockTimes' ||
1163
+ else if (kind === 'clockTimes' ||
801
1164
  kind === 'compactClockTimes' && ir.pattern.minute === '0') {
802
1165
  composed = composePoint(ir, core);
803
1166
  }
@@ -93,7 +93,7 @@ export type PlanNode = {
93
93
  times: HourTimesPlan;
94
94
  } | {
95
95
  kind: 'minuteSpanAcrossHourStep';
96
- form: 'wildcard' | 'range';
96
+ form: 'wildcard' | 'range' | 'list';
97
97
  } | {
98
98
  kind: 'everyHour';
99
99
  } | {
@@ -1,7 +1,12 @@
1
1
  declare function includes(str: string | number, sub: string): boolean;
2
2
  declare function unique<T>(items: T[]): T[];
3
3
  declare function isNonNegativeInteger(value: string): boolean;
4
+ declare function arithmeticStep(values: number[]): {
5
+ start: number;
6
+ interval: number;
7
+ last: number;
8
+ } | null;
4
9
  declare function toFieldNumber(token: string, numberMap?: {
5
10
  [name: string]: number;
6
11
  }): number;
7
- export { includes, isNonNegativeInteger, toFieldNumber, unique };
12
+ export { arithmeticStep, includes, isNonNegativeInteger, toFieldNumber, unique };