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.
@@ -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,13 +295,49 @@ 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);
315
+ }
316
+
317
+ // One hour segment as clock words by its form: a range is a span ("9点至20点"),
318
+ // a single is one clock word ("22点"), a step keeps its fires enumerated as
319
+ // clock words ("9点、11点、13点"). A range stated as a list element should read
320
+ // as the span the source wrote, not the hours it expands to — the same choice
321
+ // en/es/de/fi make ("from 9 a.m. through 8 p.m. and at 10 p.m.").
322
+ function hourSegmentWords(segment: Segment): string[] {
323
+ if (segment.kind === 'range') {
324
+ return [hourWord(+segment.bounds[0]) + '至' + hourWord(+segment.bounds[1])];
325
+ }
326
+
327
+ if (segment.kind === 'step') {
328
+ return segment.fires.map(hourWord);
329
+ }
330
+
331
+ return [hourWord(+segment.value)];
198
332
  }
199
333
 
200
- // The hour list as clock words: "9点、11点和13点".
334
+ // The hour field as clock words, by segment form: "9点、11点和13点" for a list
335
+ // of singles, "9点至20点和22点" for a range plus a single. Each segment renders
336
+ // as the operator the source wrote (range → span), not its expanded fires.
201
337
  function hourList(ir: IR): string {
202
- return joinAnd(hourFires(ir).map(hourWord));
338
+ const words = fieldSegments(ir, 'hour').flatMap(hourSegmentWords);
339
+
340
+ return joinAnd(words);
203
341
  }
204
342
 
205
343
  // A frame that confines a cadence to active hours: a range gives "在F点至T点之
@@ -218,21 +356,22 @@ function hourFrame(ir: IR): string {
218
356
  // A repeating minute step, optionally confined to active hours.
219
357
  function renderMinuteFrequency(ir: IR, plan: PlanNode): string {
220
358
  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);
359
+ // A clean stride is the bare "每N分钟" cadence; an offset step keeps its start
360
+ // ("每小时从5分起每6分钟"). A short offset cadence still lists its fires.
361
+ const base = stepClause(minuteStep, UNITS.minute, '分', '每小时');
226
362
  const {hours} = plan as Extract<PlanNode, {kind: 'minuteFrequency'}>;
227
363
 
228
- if (hours.kind === 'step') {
229
- const hourStep = stepSegment(ir, 'hour');
364
+ // An hour stride (a clean step, or an offset/non-tiling progression the core
365
+ // kept a step shape or enumerated to a list) leads the minute cadence:
366
+ // "每2小时每5分钟", "从2点起每6小时每15分钟". A clean cadence concatenates as
367
+ // before; a bounded cadence ends on "至K点", so a comma keeps that endpoint
368
+ // from gluing onto the minute clause ("从9点起每2小时,至17点,每2分钟").
369
+ if (hours.kind === 'step' || hours.kind === 'during') {
370
+ const hourCad = hourCadencePhrase(ir);
230
371
 
231
- // "每N小时" is only faithful from midnight; an offset step (2/6 fires at
232
- // 2,8,14,20) enumerates its hours instead.
233
- return hourStep.startToken === '*' ?
234
- cadence(hourStep.interval, UNITS.hour) + base :
235
- '在' + hourList(ir) + ',' + base;
372
+ if (hourCad !== null) {
373
+ return hourCad + (hourCad.indexOf('至') === -1 ? '' : ',') + base;
374
+ }
236
375
  }
237
376
 
238
377
  if (hours.kind === 'single' ||
@@ -268,16 +407,20 @@ function renderMinuteSpanInHour(ir: IR, plan: PlanNode): string {
268
407
  }
269
408
 
270
409
  // A minute clause across discrete hours. A wildcard minute reads "在9点、11点…,
271
- // 每分钟"; a ranged/listed minute names it: "9点和17点,每小时0至30分,每分钟".
410
+ // 每分钟"; a ranged/listed minute names it: "9点和17点,每小时0至30分,每分钟". An
411
+ // hour progression reads as its cadence ("从9点起每2小时,至17点,每分钟") rather
412
+ // than the enumerated hours, the same idiom the minute field uses.
272
413
  function renderMinutesAcrossHours(ir: IR, plan: PlanNode): string {
273
414
  const {form} = plan as Extract<PlanNode, {kind: 'minutesAcrossHours'}>;
415
+ const hourCad = hourCadencePhrase(ir);
274
416
 
275
417
  if (form === 'wildcard') {
276
- return '在' + hourList(ir) + ',每分钟';
418
+ return hourCad === null ?
419
+ '在' + hourList(ir) + ',每分钟' :
420
+ hourCad + ',每分钟';
277
421
  }
278
422
 
279
- return hourList(ir) + ',每小时' +
280
- valueList(fieldSegments(ir, 'minute'), '分') + ',每分钟';
423
+ return (hourCad ?? hourList(ir)) + '' + minuteHourClause(ir) + ',每分钟';
281
424
  }
282
425
 
283
426
  // A minute clause across a stepped hour field. A wildcard minute reads "每2小时
@@ -285,16 +428,23 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanNode): string {
285
428
  function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
286
429
  const hourStep = stepSegment(ir, 'hour');
287
430
  const {form} = plan as Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>;
431
+
432
+ // A minute list reads as the hour cadence plus the minute list ("每2小时,
433
+ // 每小时0、25、50分"; offset "从1点起每2小时,每小时5分和30分"), the same compaction
434
+ // the wildcard/range minute already uses, rather than the enumerated hours.
435
+ if (form === 'list') {
436
+ return hourCadencePhrase(ir) + ',' + renderMinutePast(ir);
437
+ }
438
+
288
439
  const minuteTail = form === 'wildcard' ?
289
440
  '每分钟' :
290
- '每小时' + valueList(fieldSegments(ir, 'minute'), '分') + ',每分钟';
441
+ minuteHourClause(ir) + ',每分钟';
291
442
 
292
- // An offset stride (2/6 fires at 2,8,14,20) enumerates its hours like a
293
- // discrete list; "每N小时" is faithful only from midnight.
443
+ // An offset or non-tiling stride (2/6 fires at 2,8,14,20) reads as its
444
+ // cadence ("从2点起每6小时"). A wildcard minute hangs off it with a comma; a
445
+ // named minute follows the cadence and its own comma.
294
446
  if (hourStep.startToken !== '*') {
295
- return form === 'wildcard' ?
296
- '在' + hourList(ir) + ',' + minuteTail :
297
- hourList(ir) + ',' + minuteTail;
447
+ return hourCadencePhrase(ir) + '' + minuteTail;
298
448
  }
299
449
 
300
450
  // A step-2 hour from midnight IS exactly the even hours; name them so, rather
@@ -312,6 +462,14 @@ function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
312
462
 
313
463
  // Discrete clock times: "9点", "9点和17点".
314
464
  function renderClockTimes(ir: IR, plan: PlanNode, opts: Opts): string {
465
+ // An hour step (or arithmetic-progression hour list) under a single pinned
466
+ // minute reads as a cadence rather than a cross-product of clock times.
467
+ const cad = hourCadenceText(ir);
468
+
469
+ if (cad !== null) {
470
+ return cad;
471
+ }
472
+
315
473
  const {times} = plan as Extract<PlanNode, {kind: 'clockTimes'}>;
316
474
 
317
475
  return joinAnd(times.map((t) => clockTime(t.hour, t.minute, t.second, opts)));
@@ -320,14 +478,38 @@ function renderClockTimes(ir: IR, plan: PlanNode, opts: Opts): string {
320
478
  // Compact clock times past the cap: the hour list (the minute is folded in),
321
479
  // with a fixed second appended ("…,第30秒").
322
480
  function renderCompactClockTimes(ir: IR, plan: PlanNode): string {
323
- const {minute} = plan as Extract<PlanNode, {kind: 'compactClockTimes'}>;
481
+ // An hour step (or arithmetic-progression hour list) under the single pinned
482
+ // minute reads as a cadence, not a wall of clock times. (Returns null for an
483
+ // irregular list or a range, which keep enumerating below.)
484
+ const cad = hourCadenceText(ir);
485
+
486
+ if (cad !== null) {
487
+ return cad;
488
+ }
489
+
490
+ const compact = plan as Extract<PlanNode, {kind: 'compactClockTimes'}>;
324
491
  const secs = fieldSegments(ir, 'second');
325
492
  const tail = secs.length && ir.pattern.second !== '0' ?
326
493
  ',第' + valueText(secs) + '秒' : '';
327
494
 
328
- if (minute > 0) {
329
- return '每小时' + valueList(fieldSegments(ir, 'minute'), '分') +
330
- ',在' + hourList(ir) + tail;
495
+ // A multi-valued minute (`fold` false) names its whole set, never just its
496
+ // first fire — a list starting at 0 ("*/25" -> :00,:25,:50) must keep the
497
+ // minute clause, not drop it because the leading fire is 0. The hour reads as
498
+ // its bounded cadence when its fires form a progression ("从0点起每5小时,至20
499
+ // 点"), composed after the minute set, the same idiom the stepped-hour path
500
+ // uses; an irregular hour list keeps enumerating with the "在…" frame.
501
+ if (!compact.fold) {
502
+ const hourCad = hourCadencePhrase(ir);
503
+
504
+ return hourCad === null ?
505
+ minuteHourClause(ir) + ',在' + hourList(ir) + tail :
506
+ hourCad + ',' + minuteHourClause(ir) + tail;
507
+ }
508
+
509
+ // A single pinned minute past 0 leads with its clause; a pinned 0 folds into
510
+ // the hour times (the :00 is implicit).
511
+ if (compact.minute > 0) {
512
+ return minuteHourClause(ir) + ',在' + hourList(ir) + tail;
331
513
  }
332
514
 
333
515
  return hourList(ir) + tail;
@@ -340,7 +522,7 @@ function renderHourRange(ir: IR, plan: PlanNode): string {
340
522
  if (range.minuteForm === 'lead') {
341
523
  const minuteSegs = fieldSegments(ir, 'minute');
342
524
  const past = minuteSegs.length && ir.pattern.minute !== '0' ?
343
- '每小时' + valueList(minuteSegs, '分') : '每小时';
525
+ minuteHourClause(ir) : '每小时';
344
526
 
345
527
  return '在' + hourWord(range.from) + '至' + hourWord(range.to) +
346
528
  '之间,' + past;
@@ -356,27 +538,149 @@ function renderHourRange(ir: IR, plan: PlanNode): string {
356
538
  range.last + '分之间,每分钟';
357
539
  }
358
540
 
359
- // A stepped hour field: "每2小时", or its two fires as clock words when the
360
- // stride fires only twice. An uneven stride (one that does not divide 24) is
361
- // rewritten to its fire list upstream and never reaches here.
541
+ // A stepped hour field as its cadence: "每2小时" (clean), "从1点起每2小时"
542
+ // (offset), "从9点起每2小时,至17点" (bounded). A stride that fires only twice
543
+ // reads instead as its two clock words ("凌晨0点和正午", "8点和20点"), shorter and
544
+ // clearer than a cadence for a pair.
362
545
  function renderHourStep(ir: IR): string {
363
546
  const segment = stepSegment(ir, 'hour');
364
547
 
365
- if (segment.startToken !== '*') {
366
- return hourList(ir);
367
- }
368
-
369
- // A step that fires only twice reads as two clock times ("凌晨0点和正午").
370
548
  if (segment.fires.length <= 2) {
371
549
  return joinAnd(segment.fires.map(hourWord));
372
550
  }
373
551
 
374
- return cadence(segment.interval, UNITS.hour);
552
+ return hourCadencePhrase(ir) as string;
553
+ }
554
+
555
+ // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
556
+
557
+ // The hour field's stride, or null when the hour is not a cadence: a step
558
+ // segment yields its {interval, start, last}; an all-single hour list yields
559
+ // one only when its values form a long-enough arithmetic progression (so an
560
+ // irregular list, or a too-short one like 9,17, keeps enumerating). An offset
561
+ // (start > 0) or non-tiling (interval ∤ 24) stride is still a cadence — Chinese
562
+ // names its start and endpoint ("从M点起每N小时,至K点"), the same idiom the
563
+ // minute field already uses — so it is no longer rejected. The IR is unchanged.
564
+ function hourStride(
565
+ ir: IR
566
+ ): {interval: number; start: number; last: number} | null {
567
+ const segments = fieldSegments(ir, 'hour');
568
+
569
+ if (segments.length === 1 && segments[0].kind === 'step') {
570
+ const {fires, interval} = segments[0];
571
+
572
+ return {interval, start: fires[0], last: fires[fires.length - 1]};
573
+ }
574
+
575
+ const values = singleValues(segments);
576
+
577
+ return values && arithmeticStep(values);
578
+ }
579
+
580
+ // The hour field's cadence phrase ("每2小时", "从1点起每2小时", "从0点起每5小时,
581
+ // 至20点"), or null when the hour is not a single arithmetic progression (an
582
+ // irregular list, a range, or a too-short list keeps enumerating). The 24-cycle
583
+ // analog of strideFromSegments — it routes the stride through the one phrasing
584
+ // renderStride speaks, so a clean, offset, or non-tiling hour stride all read
585
+ // as the cadence the equivalent minute step does.
586
+ function hourCadencePhrase(ir: IR): string | null {
587
+ const stride = hourStride(ir);
588
+
589
+ return stride && renderStride({
590
+ ...stride, cycle: 24, unit: UNITS.hour, mark: '点', anchor: ''
591
+ });
592
+ }
593
+
594
+ // Render an hour step (or arithmetic-progression hour list) under a single
595
+ // pinned minute and a second as a cadence — the hour cadence plus the
596
+ // minute/second — instead of cross-multiplying the hours into a wall of clock
597
+ // times. Returns null when the hour is not a stride, when the cross-product is
598
+ // short enough that enumeration is no longer than the cadence (a meaningful
599
+ // second makes every clock time carry a second, so any stride is worth
600
+ // compacting; otherwise the stride must exceed the clock-time cap, the same
601
+ // point at which the core itself stops enumerating), or when the cadence is
602
+ // bounded ("…,至K点"): a trailing minute fused onto its endpoint ("至20点0分")
603
+ // would read as a clock time, so a bounded stride keeps enumerating its fused
604
+ // clock times here, naming the cadence only where no minute follows it (the
605
+ // bare hour field). Renderer-only; the IR is unchanged.
606
+ function hourCadence(ir: IR): string | null {
607
+ const stride = hourStride(ir);
608
+
609
+ if (!stride) {
610
+ return null;
611
+ }
612
+
613
+ const fires = (stride.last - stride.start) / stride.interval + 1;
614
+
615
+ if (ir.pattern.second === '0' && fires <= maxClockTimes) {
616
+ return null;
617
+ }
618
+
619
+ const prefix = hourCadencePhrase(ir) as string;
620
+
621
+ // A bounded cadence cannot carry a fused minute unambiguously; enumerate.
622
+ if (prefix.indexOf('至') !== -1) {
623
+ return null;
624
+ }
625
+
626
+ const minute = +ir.pattern.minute;
627
+ const subMinute = ir.pattern.second === '*' || ir.shapes.second === 'step';
628
+
629
+ // A wildcard or sub-minute step second confined to minute 0 of the even-hour
630
+ // stride is a confinement, not a juxtaposed cadence. Reuse the even-hours
631
+ // idiom ("在偶数小时0分的每一秒") so the form does NOT contain the bare "每2小时"
632
+ // and can never be misread as the absorbing hour cadence (the same reason en
633
+ // says "for one minute during every other hour", not "every two hours"). The
634
+ // idiom exists only for the even-hour stride (interval 2 from midnight);
635
+ // another stride keeps enumerating (return null) rather than coin a
636
+ // misleading "…小时…" form.
637
+ if (minute === 0 && subMinute) {
638
+ return stride.interval === 2 && stride.start === 0 ?
639
+ '在偶数小时0分' + secondTail(ir) : null;
640
+ }
641
+
642
+ // A pinned minute 0 folds into the cadence with the explicit "0分" so the
643
+ // confinement stays visible ("每2小时0分的第30秒"); a non-zero minute is a real
644
+ // clock minute named after the cadence ("每2小时5分的每一秒"). A plain :00
645
+ // second adds nothing.
646
+ if (minute === 0) {
647
+ return prefix + '0分' + secondTail(ir);
648
+ }
649
+
650
+ return ir.pattern.second === '0' ?
651
+ prefix + minute + '分' :
652
+ prefix + minute + '分' + secondTail(ir);
653
+ }
654
+
655
+ // The cadence a clock-point core (clockTimes/compactClockTimes/composeSeconds)
656
+ // renders an hour stride to, or null. A bare hour stride (minute 0 on the plain
657
+ // :00 second) is the cadence phrase itself — "每2小时", "从0点起每5小时,至20点" —
658
+ // so a short non-tiling stride like */5, which hourCadence keeps enumerating
659
+ // (no minute to fold, nothing to disambiguate), still reads as the cadence. A
660
+ // pinned minute or meaningful second folds into the cadence via hourCadence.
661
+ function hourCadenceText(ir: IR): string | null {
662
+ if (ir.shapes.minute !== 'single') {
663
+ return null;
664
+ }
665
+
666
+ if (+ir.pattern.minute === 0 && ir.pattern.second === '0') {
667
+ return hourCadencePhrase(ir);
668
+ }
669
+
670
+ return hourCadence(ir);
671
+ }
672
+
673
+ // The fused second tail for an hour cadence: "的每一秒" for a wildcard second,
674
+ // else "的" + the second's own clause ("的第30秒", "的每15秒").
675
+ function secondTail(ir: IR): string {
676
+ const sec = secondClause(ir);
677
+
678
+ return sec === '每秒' ? '的每一秒' : '的' + sec;
375
679
  }
376
680
 
377
681
  // A continuous minute range fires every minute within it: "每小时0至30分,每分钟".
378
682
  function renderRangeOfMinutes(ir: IR): string {
379
- return '每小时' + valueList(fieldSegments(ir, 'minute'), '分') + ',每分钟';
683
+ return minuteHourClause(ir) + ',每分钟';
380
684
  }
381
685
 
382
686
  // A standalone second field: "每7秒" (step cadence) or "每分钟第4、17、42秒".
@@ -388,7 +692,8 @@ function renderStandaloneSeconds(ir: IR): string {
388
692
  return cadence(first.interval, UNITS.second);
389
693
  }
390
694
 
391
- return '每分钟第' + valueText(segs) + '秒';
695
+ return strideFromSegments(segs, '秒', '秒', '每分钟') ??
696
+ '每分钟第' + valueText(segs) + '秒';
392
697
  }
393
698
 
394
699
  // A second anchored to the minute: "每分钟第1秒", "每分钟第4、17、42秒".
@@ -423,7 +728,10 @@ function secondClause(ir: IR): string {
423
728
  return cadence(first.interval, UNITS.second);
424
729
  }
425
730
 
426
- return '第' + valueText(segs) + '秒';
731
+ // An offset/uneven step the core enumerated to this list reads as a stride
732
+ // cadence when the fires form a long-enough progression.
733
+ return strideFromSegments(segs, '秒', '秒', '每分钟') ??
734
+ '第' + valueText(segs) + '秒';
427
735
  }
428
736
 
429
737
  // The minute clause for a composed (seconds) schedule.
@@ -439,41 +747,46 @@ function minuteClause(ir: IR): string {
439
747
  return valueList(fieldSegments(ir, 'minute'), '分');
440
748
  }
441
749
 
442
- // Whether the hour field is a true "every N hours" cadence (vs discrete fires
443
- // like 9-17/2, whose start token is a number).
444
- function isHourCadence(ir: IR): boolean {
445
- return ir.shapes.hour === 'step' &&
446
- stepSegment(ir, 'hour').startToken === '*';
750
+ // A single second folds into each clock time a clockTimes rest renders
751
+ // ("9点5分30秒"), so it is already spoken; appending the second clause again
752
+ // would double it. A wildcard/list/range second does not fold, so it still
753
+ // leads its own clause after the clock times.
754
+ function clockRestCarriesSecond(rest: PlanNode): boolean {
755
+ return rest.kind === 'clockTimes' &&
756
+ rest.times.some((time) => Boolean(time.second));
447
757
  }
448
758
 
449
759
  // minute = 0 ("on the hour"): render the rest schedule and attach the second.
450
760
  function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
451
761
  const sec = secondClause(ir);
452
762
  const {rest} = plan as Extract<PlanNode, {kind: 'composeSeconds'}>;
763
+ const composedClock =
764
+ rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes';
453
765
 
454
- // The minute is pinned to 0 under a specific hour: a bare clock word ("9点")
455
- // would hide the :00 and leave the second dangling ("…9点每秒"), reading as
456
- // the whole hour. Fuse the seconds with the explicit clock minute ("9点0分
457
- // 的每一秒"), so the one-minute confinement (60 fires in :00, not 3,600
458
- // across the hour) stays visible. The daily frame leads with 每天; a weekday
459
- // or date qualifier is added by describe().
460
- if ((rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') &&
461
- ir.pattern.minute === '0') {
462
- const clocks = hourFires(ir).map(function clock(hour): string {
463
- return hourWord(hour) + '0分';
464
- });
465
- const tail = sec === '每秒' ? '的每一秒' : '的' + sec;
466
- const core = joinAnd(clocks) + tail;
766
+ // An hour step (or arithmetic-progression hour list) under a single pinned
767
+ // minute reads as a cadence, not a wall of clock times ("每2小时0分的第30秒").
768
+ // hourCadence folds in the second itself, so it is returned whole, never the
769
+ // rest-plus-second fall-through that would double the second clause. The
770
+ // cadence is sub-daily (no 每天); a qualifier is added by describe().
771
+ if (composedClock) {
772
+ const hourCad = hourCadence(ir);
773
+
774
+ if (hourCad !== null) {
775
+ return hourCad;
776
+ }
777
+ }
467
778
 
468
- return isDaily(ir) ? '每天' + core : core;
779
+ // The minute is pinned to 0 under specific hours that did not compact to a
780
+ // cadence: fuse the seconds with each explicit clock minute.
781
+ if (composedClock && ir.pattern.minute === '0') {
782
+ return composeMinuteZeroClocks(ir, sec);
469
783
  }
470
784
 
471
785
  const restText = render(ir, rest, opts);
786
+ const secTail = clockRestCarriesSecond(rest) ? '' : sec;
472
787
 
473
- if (rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') {
474
- if (isDaily(ir)) {
475
- return '每天' + restText + sec;
476
- }
788
+ if (composedClock && isDaily(ir)) {
789
+ return '每天' + restText + secTail;
477
790
  }
478
791
 
479
792
  // A stated minute (e.g. minute 0 under a sub-minute second) takes the same
@@ -482,7 +795,64 @@ function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
482
795
  return restText + ',' + sec;
483
796
  }
484
797
 
485
- return restText + sec;
798
+ return restText + secTail;
799
+ }
800
+
801
+ // A minute pinned to 0 under specific clock hours (not a compacted cadence): a
802
+ // bare clock word ("9点") would hide the :00 and leave the second dangling
803
+ // ("…9点每秒"), reading as the whole hour. Fuse the seconds with the explicit
804
+ // clock minute ("9点0分的每一秒"), so the one-minute confinement (60 fires in
805
+ // :00, not 3,600 across the hour) stays visible. The daily frame leads with
806
+ // 每天; a weekday or date qualifier is added by describe().
807
+ function composeMinuteZeroClocks(ir: IR, sec: string): string {
808
+ // An hour RANGE (or a list whose segments include a range) reads as the span
809
+ // the source wrote ("9点至17点"), not the wall of clock words it expands to —
810
+ // the hour-RANGE analog of the hour-step cadence. A pure single-value list
811
+ // (9,17) has no range to span and keeps enumerating below.
812
+ if (hasHourWindow(ir)) {
813
+ return isDaily(ir) ? '每天' + hourRangeWindow(ir, sec) :
814
+ hourRangeWindow(ir, sec);
815
+ }
816
+
817
+ const clocks = hourFires(ir).map(function clock(hour): string {
818
+ // Noon's word (正午) already pins 12:00, so the "0分" is redundant for it;
819
+ // midnight (凌晨0点) and other hours still need it to pin the minute.
820
+ return hour === 12 ? '正午' : hourWord(hour) + '0分';
821
+ });
822
+ // A pinned minute makes the seconds' own "每分钟" anchor misleading (it is a
823
+ // single minute, not every minute), so the stride here drops it.
824
+ const nested = strideFromSegments(fieldSegments(ir, 'second'), '秒', '秒', '');
825
+ const tail = sec === '每秒' ? '的每一秒' : '的' + (nested ?? sec);
826
+ const core = joinAnd(clocks) + tail;
827
+
828
+ return isDaily(ir) ? '每天' + core : core;
829
+ }
830
+
831
+ // Whether the hour field is a range — or a list whose segments include a
832
+ // range — and so forms a window ("9点至17点") rather than a wall of clock
833
+ // words. A pure single-value list (9,17) has no range to span; a step is
834
+ // handled by hourStride/hourCadence.
835
+ function hasHourWindow(ir: IR): boolean {
836
+ return fieldSegments(ir, 'hour').some(function range(segment) {
837
+ return segment.kind === 'range';
838
+ });
839
+ }
840
+
841
+ // The hour-range window under a pinned minute 0 and a meaningful or wildcard
842
+ // second: the hour span list ("9点至17点", "9点至20点和22点") plus the second.
843
+ // A wildcard or sub-minute step second pins the explicit "0分" so the
844
+ // one-minute confinement stays visible ("9点至17点0分的每一秒"), distinct from
845
+ // the bare hourly window ("在9点至17点之间,每小时"); a single/list/range second
846
+ // reads as a clock-point span with the second appended ("9点至17点,第30秒"),
847
+ // matching the folded compact form for the same shape.
848
+ function hourRangeWindow(ir: IR, sec: string): string {
849
+ const span = hourList(ir);
850
+
851
+ if (ir.pattern.second === '*' || ir.shapes.second === 'step') {
852
+ return span + '0分' + (sec === '每秒' ? '的每一秒' : '的' + sec);
853
+ }
854
+
855
+ return span + ',' + sec;
486
856
  }
487
857
 
488
858
  // Wildcard or stepped minute: hang the "每分钟/每N分钟每秒" tail off the hour.
@@ -490,8 +860,13 @@ function composeSecondsCadence(ir: IR): string {
490
860
  const sec = secondClause(ir);
491
861
  const tail = minuteClause(ir) + sec;
492
862
 
493
- if (isHourCadence(ir)) {
494
- return cadence(stepSegment(ir, 'hour').interval, UNITS.hour) + '的' + tail;
863
+ const hourCad = hourCadencePhrase(ir);
864
+
865
+ if (hourCad !== null) {
866
+ // The cadence absorbs the tail with "的" ("每2小时的每分钟每秒",
867
+ // "从1点起每2小时的每分钟每秒"); a bounded cadence ends on "至K点", so its tail
868
+ // takes a comma to keep that endpoint from reading as a fused clock time.
869
+ return hourCad + (hourCad.indexOf('至') === -1 ? '的' : ',') + tail;
495
870
  }
496
871
 
497
872
  if (ir.shapes.hour === 'single') {
@@ -499,6 +874,18 @@ function composeSecondsCadence(ir: IR): string {
499
874
  }
500
875
 
501
876
  if (ir.shapes.hour === 'wildcard') {
877
+ // "每秒,每2分钟" juxtaposes two cadences that read as contradictory. A
878
+ // step-2 minute from the top of the hour IS exactly the even minutes; bind
879
+ // the every-second cadence to them ("每偶数分钟的每一秒") rather than listing
880
+ // the two side by side. Other strides keep the juxtaposed form.
881
+ if (ir.shapes.minute === 'step' && sec === '每秒') {
882
+ const minuteStep = stepSegment(ir, 'minute');
883
+
884
+ if (minuteStep.startToken === '*' && minuteStep.interval === 2) {
885
+ return '每偶数分钟的每一秒';
886
+ }
887
+ }
888
+
502
889
  return sec + ',' + minuteClause(ir);
503
890
  }
504
891
 
@@ -511,23 +898,30 @@ function composeSecondsCadence(ir: IR): string {
511
898
  // last fire onto the window end ("…17点30分") and reading as a continuous span.
512
899
  function composeSecondsListed(ir: IR): string {
513
900
  const sec = secondClause(ir);
514
- const minutes = '每小时' + valueList(fieldSegments(ir, 'minute'), '分');
901
+ const minutes = minuteHourClause(ir);
515
902
 
516
903
  // A single restricted hour with an every-second cadence fuses the clock time
517
904
  // with its minutes — "凌晨0点5、20、35、50分的每一秒" — rather than the "每小时"
518
905
  // that falsely implies every hour. A non-wildcard second keeps the list form.
906
+ // A non-uniform minute step the core enumerated to a fire list reads as its
907
+ // stride cadence ("凌晨0点从3分起每2分钟,至59分的每一秒"); the hour fuses, so the
908
+ // stride drops its "每小时" anchor. A short or irregular set keeps the list.
519
909
  if (ir.shapes.hour === 'single' && sec === '每秒') {
520
- return hourWord(hourFires(ir)[0]) +
521
- valueList(fieldSegments(ir, 'minute'), '分') + '的每一秒';
910
+ const minuteSegs = fieldSegments(ir, 'minute');
911
+ const minuteCad = strideFromSegments(minuteSegs, '分钟', '分', '') ??
912
+ valueList(minuteSegs, '分');
913
+
914
+ return hourWord(hourFires(ir)[0]) + minuteCad + '的每一秒';
522
915
  }
523
916
 
524
917
  if (ir.shapes.hour === 'wildcard') {
525
918
  return minutes + ',' + sec;
526
919
  }
527
920
 
528
- if (isHourCadence(ir)) {
529
- return cadence(stepSegment(ir, 'hour').interval, UNITS.hour) + ',' +
530
- minutes + ',' + sec;
921
+ const hourCad = hourCadencePhrase(ir);
922
+
923
+ if (hourCad !== null) {
924
+ return hourCad + ',' + minutes + ',' + sec;
531
925
  }
532
926
 
533
927
  return hourFrame(ir) + minutes + ',' + sec;
@@ -861,14 +1255,27 @@ function composeWindow(ir: IR, core: string): string {
861
1255
  return qualifier(ir) + core;
862
1256
  }
863
1257
 
1258
+ // Whether an hour cadence applies — a single pinned minute over an hour stride
1259
+ // (clean, offset, or non-tiling) — so the clock-point plans take the cadence
1260
+ // frame, not the daily one.
1261
+ function hourCadenceApplies(ir: IR): boolean {
1262
+ return hourCadenceText(ir) !== null;
1263
+ }
1264
+
864
1265
  function describe(ir: IR, opts: Opts): string {
865
1266
  const {kind} = ir.plan;
866
1267
  const core = render(ir, ir.plan, opts);
867
1268
  let composed = core;
868
1269
 
1270
+ // An hour step (or arithmetic-progression hour list) under a single pinned
1271
+ // minute is a sub-daily cadence, not a daily clock point — it takes the
1272
+ // cadence frame (no 每天), like the bare "每2小时" form.
1273
+ if (hourCadenceApplies(ir)) {
1274
+ composed = composeCadence(ir, core);
1275
+ }
869
1276
  // A compact clock list with a minute past the hour ("每小时5分…") reads as a
870
1277
  // cadence, not a daily clock point — no 每天.
871
- if (kind === 'clockTimes' ||
1278
+ else if (kind === 'clockTimes' ||
872
1279
  kind === 'compactClockTimes' && ir.pattern.minute === '0') {
873
1280
  composed = composePoint(ir, core);
874
1281
  }