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/CHANGELOG.md +53 -0
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +286 -45
- package/dist/cronli5.js +286 -45
- package/dist/lang/de.cjs +252 -13
- package/dist/lang/de.js +252 -13
- package/dist/lang/en.cjs +281 -38
- package/dist/lang/en.js +281 -38
- package/dist/lang/es.cjs +259 -29
- package/dist/lang/es.js +259 -29
- package/dist/lang/fi.cjs +285 -49
- package/dist/lang/fi.js +285 -49
- package/dist/lang/zh.cjs +225 -42
- package/dist/lang/zh.js +225 -42
- package/package.json +3 -2
- package/src/core/analyze.ts +7 -0
- package/src/core/ir.ts +1 -1
- package/src/core/util.ts +31 -1
- package/src/lang/de/index.ts +561 -30
- package/src/lang/en/index.ts +593 -59
- package/src/lang/es/index.ts +576 -52
- package/src/lang/fi/index.ts +633 -95
- package/src/lang/zh/index.ts +484 -77
- package/types/core/ir.d.ts +1 -1
- package/types/core/util.d.ts +6 -1
package/src/lang/de/index.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// German. Anchored to Duden; see notes.md for the decisions.
|
|
3
3
|
|
|
4
4
|
import {pad} from '../../core/format.js';
|
|
5
|
-
import {weekdayNumbers} from '../../core/specs.js';
|
|
6
|
-
import {toFieldNumber} from '../../core/util.js';
|
|
5
|
+
import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
|
|
6
|
+
import {arithmeticStep, toFieldNumber} from '../../core/util.js';
|
|
7
7
|
import type {Cronli5Options} from '../../types.js';
|
|
8
8
|
import type {
|
|
9
9
|
Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
|
|
@@ -22,6 +22,19 @@ interface Unit {
|
|
|
22
22
|
singular: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// A step cadence to phrase: the `interval` repeats over a `cycle`-long field
|
|
26
|
+
// (60 for minute/second), running from `start` to `last`. `anchor` is the
|
|
27
|
+
// scope clause ("jeder Stunde"); an empty anchor lets the caller supply its
|
|
28
|
+
// own trailing scope, dropping the tail.
|
|
29
|
+
interface Stride {
|
|
30
|
+
interval: number;
|
|
31
|
+
start: number;
|
|
32
|
+
last: number;
|
|
33
|
+
cycle: number;
|
|
34
|
+
unit: Unit;
|
|
35
|
+
anchor: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
const UNITS: Record<'second' | 'minute' | 'hour', Unit> = {
|
|
26
39
|
hour: {every: 'jede', plural: 'Stunden', singular: 'Stunde'},
|
|
27
40
|
minute: {every: 'jede', plural: 'Minuten', singular: 'Minute'},
|
|
@@ -51,6 +64,98 @@ function cleanStep(segment: StepSegment, cycle: number): boolean {
|
|
|
51
64
|
cycle % segment.interval === 0;
|
|
52
65
|
}
|
|
53
66
|
|
|
67
|
+
// Speak a step cadence over a `cycle`-long field (60 for minute/second). A
|
|
68
|
+
// clean stride from the top of the cycle is the bare cadence ("alle 15
|
|
69
|
+
// Minuten"); a uniform offset (start within the first interval, the interval
|
|
70
|
+
// still dividing the cycle) names only its start, since it wraps cleanly with
|
|
71
|
+
// no distinct endpoint ("alle 6 Minuten ab Minute 5 jeder Stunde"); a
|
|
72
|
+
// non-uniform stride (start >= interval, or an interval that does not divide
|
|
73
|
+
// the cycle) pins both endpoints so the bounded, non-wrapping set reads
|
|
74
|
+
// unambiguously ("alle 2 Minuten von Minute 3 bis 59 jeder Stunde"). This is
|
|
75
|
+
// the one phrasing for every step the renderer speaks, whether the core kept
|
|
76
|
+
// it a step shape (a clean cadence) or enumerated it to a fire list (an
|
|
77
|
+
// offset/uneven set the list path recognizes as a progression).
|
|
78
|
+
function renderStride(stride: Stride): string {
|
|
79
|
+
const {interval, start, last, cycle, unit, anchor} = stride;
|
|
80
|
+
const cadence = everyN(interval, unit);
|
|
81
|
+
const tiles = cycle % interval === 0;
|
|
82
|
+
|
|
83
|
+
if (start === 0 && tiles) {
|
|
84
|
+
return cadence;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A context that supplies its own trailing scope passes an empty anchor, so
|
|
88
|
+
// the cadence keeps its endpoints but drops the "jeder Stunde" tail.
|
|
89
|
+
const tail = anchor ? ' ' + anchor : '';
|
|
90
|
+
|
|
91
|
+
if (start < interval && tiles) {
|
|
92
|
+
return cadence + ' ab ' + unit.singular + ' ' + start + tail;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return cadence + ' von ' + unit.singular + ' ' + start + ' bis ' + last +
|
|
96
|
+
tail;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A step *shape* segment as its cadence ("alle 6 Minuten ab Minute 5 jeder
|
|
100
|
+
// Stunde"). A bounded sub-range step (`a-b/n`) is not a whole-cycle stride, so
|
|
101
|
+
// it lists its fires; a short offset cadence (three fires or fewer) also lists,
|
|
102
|
+
// since the list is no longer than the cadence. Everything else routes through
|
|
103
|
+
// `renderStride`. The uneven whole-cycle step (interval not dividing the cycle)
|
|
104
|
+
// never reaches here as a step shape — the core enumerates it to a fire list,
|
|
105
|
+
// which the list path recognizes instead.
|
|
106
|
+
function stepClause(segment: StepSegment, unit: Unit, anchor: string): string {
|
|
107
|
+
const start = segment.startToken === '*' ? 0 : +segment.startToken;
|
|
108
|
+
const short = start !== 0 && segment.fires.length <= 3;
|
|
109
|
+
|
|
110
|
+
if (segment.startToken.indexOf('-') !== -1 || short) {
|
|
111
|
+
return 'in den ' + unit.plural + ' ' + joinList(segment.fires.map(String)) +
|
|
112
|
+
' ' + anchor;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return renderStride({
|
|
116
|
+
interval: segment.interval,
|
|
117
|
+
start,
|
|
118
|
+
last: segment.fires[segment.fires.length - 1],
|
|
119
|
+
cycle: 60,
|
|
120
|
+
unit,
|
|
121
|
+
anchor
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The sorted numeric values a field's segments cover, or null if any segment
|
|
126
|
+
// is not a discrete single (a range or sub-step is not a plain fire list).
|
|
127
|
+
function singleValues(segments: Segment[]): number[] | null {
|
|
128
|
+
const values: number[] = [];
|
|
129
|
+
|
|
130
|
+
for (const segment of segments) {
|
|
131
|
+
if (segment.kind !== 'single') {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
values.push(+segment.value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return values;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Speak a minute/second field's enumerated fires as a step cadence when they
|
|
142
|
+
// form an arithmetic progression long enough to beat the list (the core
|
|
143
|
+
// enumerates an offset/uneven step to this fire list; the IR is unchanged, so
|
|
144
|
+
// the renderer recognizes the progression). Returns null for a non-progression
|
|
145
|
+
// or a too-short list, leaving the caller to enumerate.
|
|
146
|
+
function strideFromSegments(
|
|
147
|
+
segments: Segment[],
|
|
148
|
+
unit: Unit,
|
|
149
|
+
anchor: string
|
|
150
|
+
): string | null {
|
|
151
|
+
const values = singleValues(segments);
|
|
152
|
+
const step = values && arithmeticStep(values);
|
|
153
|
+
|
|
154
|
+
return step ?
|
|
155
|
+
renderStride({...step, cycle: 60, unit, anchor}) :
|
|
156
|
+
null;
|
|
157
|
+
}
|
|
158
|
+
|
|
54
159
|
type NameToken = string | number;
|
|
55
160
|
type NameSegment =
|
|
56
161
|
| {kind: 'single'; value: NameToken}
|
|
@@ -142,6 +247,20 @@ function everyNthHour(segment: StepSegment): string {
|
|
|
142
247
|
return start === 0 ? base : base + ' ab ' + start + ' Uhr';
|
|
143
248
|
}
|
|
144
249
|
|
|
250
|
+
// Whether an hour step is a clean stride over the whole day — unbounded,
|
|
251
|
+
// dividing 24, and starting within the first interval — so it confines to "in
|
|
252
|
+
// jeder N-ten Stunde" rather than enumerating its fires. Mirrors the core's
|
|
253
|
+
// cleanHourStride: an offset like 1/2 qualifies; bounded (9-17/2) does not.
|
|
254
|
+
function confinedHourStride(segment: StepSegment): boolean {
|
|
255
|
+
if (segment.startToken.indexOf('-') !== -1) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const start = segment.startToken === '*' ? 0 : +segment.startToken;
|
|
260
|
+
|
|
261
|
+
return 24 % segment.interval === 0 && start < segment.interval;
|
|
262
|
+
}
|
|
263
|
+
|
|
145
264
|
// The Quartz weekday stem (`5L`, `MON#2`) is not number-canonicalized in the
|
|
146
265
|
// core, so it may still be a name token; resolve it via the core's index.
|
|
147
266
|
function weekdayNoun(token: string): string {
|
|
@@ -344,17 +463,30 @@ function countedPhrase(
|
|
|
344
463
|
// The seconds clause: "alle 30 Sekunden" for a step, else "in Sekunde 15
|
|
345
464
|
// jeder Minute".
|
|
346
465
|
function secondsLead(ir: IR): string {
|
|
466
|
+
return secondsClause(ir, 'jeder Minute');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// The second clause counted against an arbitrary anchor. The anchor is "jeder
|
|
470
|
+
// Minute" in the standalone seconds path; the hour-cadence path folds a pinned
|
|
471
|
+
// minute 0 into the hour and counts the second "jeder Stunde" instead ("in
|
|
472
|
+
// Sekunde 30 jeder Stunde"), so the minute-0 confinement is stated, not
|
|
473
|
+
// dropped.
|
|
474
|
+
function secondsClause(ir: IR, anchor: string): string {
|
|
347
475
|
if (ir.pattern.second === '*') {
|
|
348
476
|
return 'jede Sekunde';
|
|
349
477
|
}
|
|
350
478
|
|
|
351
479
|
const segments = ir.analyses.segments.second;
|
|
352
480
|
|
|
353
|
-
|
|
354
|
-
|
|
481
|
+
// A step shape speaks its cadence directly; an offset/uneven step the core
|
|
482
|
+
// enumerated to a list is recognized as a progression. Both fall back to the
|
|
483
|
+
// counted list (a short or irregular set).
|
|
484
|
+
if (ir.shapes.second === 'step') {
|
|
485
|
+
return stepClause(stepSegment(segments), UNITS.second, anchor);
|
|
355
486
|
}
|
|
356
487
|
|
|
357
|
-
return
|
|
488
|
+
return strideFromSegments(segments as Segment[], UNITS.second, anchor) ??
|
|
489
|
+
countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ' ' + anchor;
|
|
358
490
|
}
|
|
359
491
|
|
|
360
492
|
// A clock time that always shows its minutes: "9:00", "9:30".
|
|
@@ -476,9 +608,17 @@ function renderSeconds(ir: IR): string {
|
|
|
476
608
|
}
|
|
477
609
|
|
|
478
610
|
// The minute-past-the-hour clause: "in Minute 5 jeder Stunde", "in den
|
|
479
|
-
// Minuten 5, 10 und 30 jeder Stunde".
|
|
611
|
+
// Minuten 5, 10 und 30 jeder Stunde". An offset/uneven step the core
|
|
612
|
+
// enumerated to this list reads as a stride cadence when the fires form a
|
|
613
|
+
// long-enough progression ("alle 2 Minuten von Minute 3 bis 59 jeder Stunde").
|
|
614
|
+
function minutePastClause(ir: IR): string {
|
|
615
|
+
return strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute,
|
|
616
|
+
'jeder Stunde') ??
|
|
617
|
+
countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
|
|
618
|
+
}
|
|
619
|
+
|
|
480
620
|
function renderMinutePast(ir: IR): string {
|
|
481
|
-
return
|
|
621
|
+
return minutePastClause(ir);
|
|
482
622
|
}
|
|
483
623
|
|
|
484
624
|
// A specific minute and second: "in Minute 0 und Sekunde 30 jeder Stunde".
|
|
@@ -530,11 +670,45 @@ function renderMinuteSpanInHour(
|
|
|
530
670
|
|
|
531
671
|
// Seconds composed with the rest: "in den Sekunden 0 und 30 jeder Minute, um
|
|
532
672
|
// 9:05 Uhr".
|
|
673
|
+
// A wildcard second under a minute */2 with a wildcard hour juxtaposes two
|
|
674
|
+
// cadences that read as contradictory ("jede Sekunde, alle 2 Minuten"). Bind
|
|
675
|
+
// them in the genitive ("jede Sekunde jeder zweiten Minute"), mirroring
|
|
676
|
+
// English. Other strides, a restricted hour, and an hour cadence keep the
|
|
677
|
+
// juxtaposed form.
|
|
678
|
+
function isEveryOtherMinuteSeconds(
|
|
679
|
+
ir: IR,
|
|
680
|
+
plan: Extract<PlanNode, {kind: 'composeSeconds'}>
|
|
681
|
+
): boolean {
|
|
682
|
+
if (plan.rest.kind !== 'minuteFrequency' ||
|
|
683
|
+
ir.shapes.second !== 'wildcard' || ir.shapes.hour !== 'wildcard') {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const minuteStep = stepSegment(ir.analyses.segments.minute);
|
|
688
|
+
|
|
689
|
+
return minuteStep.startToken === '*' && minuteStep.interval === 2;
|
|
690
|
+
}
|
|
691
|
+
|
|
533
692
|
function renderComposeSeconds(
|
|
534
693
|
ir: IR,
|
|
535
694
|
plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
|
|
536
695
|
opts: Opts
|
|
537
696
|
): string {
|
|
697
|
+
// An hour step (or arithmetic-progression hour list) under a single pinned
|
|
698
|
+
// minute is a cadence, not a wall of clock times: the second/minute lead,
|
|
699
|
+
// then the hour cadence ("in Sekunde 30 jeder Stunde, alle 2 Stunden"). The
|
|
700
|
+
// clock-time rest would otherwise cross-multiply the hours.
|
|
701
|
+
if ((plan.rest.kind === 'clockTimes' ||
|
|
702
|
+
plan.rest.kind === 'compactClockTimes') &&
|
|
703
|
+
ir.shapes.minute === 'single') {
|
|
704
|
+
const minute = +ir.pattern.minute;
|
|
705
|
+
const cadence = hourCadence(ir, minute) ?? hourRangeCadence(ir, minute);
|
|
706
|
+
|
|
707
|
+
if (cadence !== null) {
|
|
708
|
+
return cadence;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
538
712
|
// A sub-minute second with the minute pinned to 0 and a specific hour: the
|
|
539
713
|
// clock-time rest would read "um 9 Uhr", which hides the pinned :00 (and so
|
|
540
714
|
// the one-minute confinement — 60 fires in :00, not 3,600 across the hour).
|
|
@@ -545,7 +719,21 @@ function renderComposeSeconds(
|
|
|
545
719
|
clockMinuteGenitive(plan.rest.times, opts.style.sep);
|
|
546
720
|
}
|
|
547
721
|
|
|
548
|
-
|
|
722
|
+
// A wildcard second under a minute */2 with a wildcard hour binds in the
|
|
723
|
+
// genitive ("jede Sekunde jeder zweiten Minute").
|
|
724
|
+
if (isEveryOtherMinuteSeconds(ir, plan)) {
|
|
725
|
+
return secondsLead(ir) + ' jeder zweiten Minute';
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// A compact clock-time rest folds a meaningful SINGLE second into its own
|
|
729
|
+
// leading clause, so the composer must not prepend a second lead that would
|
|
730
|
+
// double it. A wildcard or stepped second is not folded there (no
|
|
731
|
+
// clockSecond), so it still leads its own clause here.
|
|
732
|
+
const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
|
|
733
|
+
ir.analyses.clockSecond;
|
|
734
|
+
const lead = restOwnsLead ? '' : secondsLead(ir) + ', ';
|
|
735
|
+
|
|
736
|
+
return lead + render(ir, plan.rest, opts);
|
|
549
737
|
}
|
|
550
738
|
|
|
551
739
|
// True when a compose-seconds plan is a sub-minute second over a minute-0
|
|
@@ -584,34 +772,62 @@ function renderMinutesAcrossHours(
|
|
|
584
772
|
opts: Opts
|
|
585
773
|
): string {
|
|
586
774
|
const sep = opts.style.sep;
|
|
775
|
+
// A bounded or uneven hour stride reads as its endpoint-pinning cadence,
|
|
776
|
+
// not a wall of hour columns.
|
|
777
|
+
const cadence = unevenHourCadence(ir);
|
|
587
778
|
|
|
588
779
|
// The wildcard form means every minute *during* each hour: render windows.
|
|
589
780
|
if (plan.form === 'wildcard') {
|
|
590
|
-
return
|
|
781
|
+
return cadence ?
|
|
782
|
+
'jede Minute, ' + cadence :
|
|
783
|
+
'jede Minute ' + duringHours(ir, plan.times, sep);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const minuteLead =
|
|
787
|
+
strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
|
|
788
|
+
countedPhrase(ir, 'minute', 'Minute', 'Minuten');
|
|
789
|
+
|
|
790
|
+
if (cadence !== null) {
|
|
791
|
+
return minuteLead + ', ' + cadence;
|
|
591
792
|
}
|
|
592
793
|
|
|
593
794
|
const hours = plan.times.kind === 'fires' ?
|
|
594
795
|
atHours(plan.times.fires) :
|
|
595
796
|
joinList(hourSegmentParts(ir, 0, 0, sep));
|
|
596
797
|
|
|
597
|
-
return
|
|
798
|
+
return minuteLead + ', ' + hours;
|
|
598
799
|
}
|
|
599
800
|
|
|
600
801
|
// A minute clause across a stepped hour range. A wildcard minute (a cadence)
|
|
601
802
|
// is reached only for a clean step and is confined to every Nth hour ("jede
|
|
602
|
-
// Minute in jeder zweiten Stunde"); a
|
|
603
|
-
//
|
|
803
|
+
// Minute in jeder zweiten Stunde"); a range or list leads with its minutes and
|
|
804
|
+
// trails the same cadence ("in den Minuten 0 bis 30, in jeder zweiten Stunde").
|
|
604
805
|
function renderMinuteSpanAcrossHourStep(
|
|
605
806
|
ir: IR,
|
|
606
807
|
plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>
|
|
607
808
|
): string {
|
|
809
|
+
// A bounded or uneven hour stride reads as its endpoint-pinning cadence; an
|
|
810
|
+
// offset-clean stride keeps its "in jeder N-ten Stunde" confinement.
|
|
811
|
+
const cadence = unevenHourCadence(ir);
|
|
812
|
+
|
|
813
|
+
// A wildcard minute over a stepped hour is reached only for a clean stride (a
|
|
814
|
+
// bounded or uneven step routes through minutesAcrossHours instead).
|
|
608
815
|
if (plan.form === 'wildcard') {
|
|
609
816
|
return 'jede Minute ' +
|
|
610
817
|
everyNthHour(stepSegment(ir.analyses.segments.hour));
|
|
611
818
|
}
|
|
612
819
|
|
|
613
|
-
|
|
614
|
-
|
|
820
|
+
// The minute (range or list) leads; the hour trails. A clean stride confines
|
|
821
|
+
// to "in jeder N-ten Stunde" — the same cadence the wildcard form and the
|
|
822
|
+
// minute-step compositions use, never a juxtaposed second frequency. A
|
|
823
|
+
// bounded or uneven stride trails its endpoint-pinning cadence instead.
|
|
824
|
+
const segment = stepSegment(ir.analyses.segments.hour);
|
|
825
|
+
const hours = cadence ?? (confinedHourStride(segment) ?
|
|
826
|
+
everyNthHour(segment) :
|
|
827
|
+
atHours(segment.fires));
|
|
828
|
+
|
|
829
|
+
return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
|
|
830
|
+
countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
|
|
615
831
|
}
|
|
616
832
|
|
|
617
833
|
// Compact minutes across discrete hours: "in den Minuten 5 und 10, um 9, 17,
|
|
@@ -627,6 +843,16 @@ function renderCompactClockTimes(
|
|
|
627
843
|
const sep = opts.style.sep;
|
|
628
844
|
|
|
629
845
|
if (plan.fold) {
|
|
846
|
+
// An hour step or range (or arithmetic-progression hour list) under the
|
|
847
|
+
// single pinned minute reads as a cadence or window, not a wall of clock
|
|
848
|
+
// times. (Returns null for an irregular list, which keeps folding below.)
|
|
849
|
+
const cadence = hourCadence(ir, plan.minute) ??
|
|
850
|
+
hourRangeCadence(ir, plan.minute);
|
|
851
|
+
|
|
852
|
+
if (cadence !== null) {
|
|
853
|
+
return cadence;
|
|
854
|
+
}
|
|
855
|
+
|
|
630
856
|
const hourly = fieldSegments(ir, 'hour')
|
|
631
857
|
.some((segment) => segment.kind === 'range');
|
|
632
858
|
|
|
@@ -634,11 +860,12 @@ function renderCompactClockTimes(
|
|
|
634
860
|
joinList(hourSegmentParts(ir, plan.minute, ir.analyses.clockSecond, sep));
|
|
635
861
|
}
|
|
636
862
|
|
|
637
|
-
// A
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
863
|
+
// A bounded or uneven hour stride reads as its endpoint-pinning cadence; else
|
|
864
|
+
// a range among the hours reads as a window, otherwise a flat hour list.
|
|
865
|
+
const hours = unevenHourCadence(ir) ??
|
|
866
|
+
(fieldSegments(ir, 'hour').some((segment) => segment.kind === 'range') ?
|
|
867
|
+
joinList(hourSegmentParts(ir, 0, 0, sep)) :
|
|
868
|
+
atHours(hourFires(ir)));
|
|
642
869
|
|
|
643
870
|
// A folded second has no single clock time to attach to here, so it leads
|
|
644
871
|
// as its own clause ("in Sekunde 30, ..."). It is the bare second (not
|
|
@@ -647,7 +874,8 @@ function renderCompactClockTimes(
|
|
|
647
874
|
countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ', ' : '';
|
|
648
875
|
|
|
649
876
|
return lead +
|
|
650
|
-
|
|
877
|
+
(strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
|
|
878
|
+
countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
|
|
651
879
|
}
|
|
652
880
|
|
|
653
881
|
// A repeating minute step, optionally within an hour window: "alle 5
|
|
@@ -660,9 +888,7 @@ function renderMinuteFrequency(
|
|
|
660
888
|
const segment = stepSegment(ir.analyses.segments.minute);
|
|
661
889
|
const sep = opts.style.sep;
|
|
662
890
|
const clean = cleanStep(segment, 60);
|
|
663
|
-
const base =
|
|
664
|
-
everyN(segment.interval, UNITS.minute) :
|
|
665
|
-
countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
|
|
891
|
+
const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
|
|
666
892
|
|
|
667
893
|
if (plan.hours.kind === 'window') {
|
|
668
894
|
const window = hourWindow(plan.hours.from, plan.hours.to, plan.hours.last,
|
|
@@ -672,7 +898,14 @@ function renderMinuteFrequency(
|
|
|
672
898
|
}
|
|
673
899
|
|
|
674
900
|
if (plan.hours.kind === 'during') {
|
|
675
|
-
|
|
901
|
+
// A bounded or uneven hour stride confines the minute cadence to its own
|
|
902
|
+
// endpoint-pinning hour cadence ("alle 15 Minuten, alle 5 Stunden von 0 bis
|
|
903
|
+
// 20 Uhr").
|
|
904
|
+
const cadence = unevenHourCadence(ir);
|
|
905
|
+
|
|
906
|
+
return cadence ?
|
|
907
|
+
base + ', ' + cadence :
|
|
908
|
+
base + ' ' + duringHours(ir, plan.hours.times, sep);
|
|
676
909
|
}
|
|
677
910
|
|
|
678
911
|
if (plan.hours.kind === 'step') {
|
|
@@ -685,10 +918,16 @@ function renderMinuteFrequency(
|
|
|
685
918
|
return base;
|
|
686
919
|
}
|
|
687
920
|
|
|
688
|
-
// A stepped hour field as a phrase:
|
|
689
|
-
//
|
|
690
|
-
// Uhr"). Shared by the bare hour step and the minute-step compositions.
|
|
921
|
+
// A stepped hour field as a phrase: an offset-clean stride is its bare or "ab"
|
|
922
|
+
// cadence; a bounded or uneven stride pins both ends ("alle 2 Stunden von 9
|
|
923
|
+
// bis 17 Uhr"). Shared by the bare hour step and the minute-step compositions.
|
|
691
924
|
function hourStepPhrase(ir: IR): string {
|
|
925
|
+
const cadence = unevenHourCadence(ir);
|
|
926
|
+
|
|
927
|
+
if (cadence !== null) {
|
|
928
|
+
return cadence;
|
|
929
|
+
}
|
|
930
|
+
|
|
692
931
|
const segment = stepSegment(ir.analyses.segments.hour);
|
|
693
932
|
|
|
694
933
|
return cleanStep(segment, 24) ?
|
|
@@ -696,6 +935,273 @@ function hourStepPhrase(ir: IR): string {
|
|
|
696
935
|
atHours(segment.fires);
|
|
697
936
|
}
|
|
698
937
|
|
|
938
|
+
// --- Hour-step cadence (the 24-cycle analog of renderStride). ---
|
|
939
|
+
|
|
940
|
+
// Speak an hour stride as a cadence with clock-time bounds: a clean stride
|
|
941
|
+
// from midnight is the bare cadence ("alle 2 Stunden"); a clean offset names
|
|
942
|
+
// only its start ("alle 6 Stunden ab 2 Uhr"); a bounded or non-tiling stride
|
|
943
|
+
// pins both clock-time endpoints ("alle 2 Stunden von 9 bis 17 Uhr") so the
|
|
944
|
+
// bounded set reads unambiguously. Used wherever an hour step (or
|
|
945
|
+
// arithmetic-progression hour list) would otherwise be cross-multiplied into a
|
|
946
|
+
// wall of clock times.
|
|
947
|
+
function hourStrideCadence(
|
|
948
|
+
stride: {start: number; interval: number; last: number}
|
|
949
|
+
): string {
|
|
950
|
+
const {start, interval, last} = stride;
|
|
951
|
+
const cadence = everyN(interval, UNITS.hour);
|
|
952
|
+
const tiles = 24 % interval === 0;
|
|
953
|
+
|
|
954
|
+
if (start === 0 && tiles) {
|
|
955
|
+
return cadence;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (start < interval && tiles) {
|
|
959
|
+
return cadence + ' ab ' + start + ' Uhr';
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return cadence + ' von ' + start + ' bis ' + last + ' Uhr';
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// An hour list's arithmetic progression, or null when its values are not a step
|
|
966
|
+
// the renderer should speak as a cadence. The core rewrites a uneven hour step
|
|
967
|
+
// (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its literal
|
|
968
|
+
// fire list, indistinguishable in the IR from a hand-written list; the renderer
|
|
969
|
+
// recovers the cadence from the values. A progression starting at zero is a
|
|
970
|
+
// `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is only
|
|
971
|
+
// a step when it is too long to be a deliberate clock-time list (9,17 is two
|
|
972
|
+
// named times, not a cadence). Interval one is a plain range, never a step.
|
|
973
|
+
function hourListStride(
|
|
974
|
+
values: number[]
|
|
975
|
+
): {start: number; interval: number; last: number} | null {
|
|
976
|
+
if (values.length < 2) {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const interval = values[1] - values[0];
|
|
981
|
+
|
|
982
|
+
if (interval < 2) {
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
for (let i = 2; i < values.length; i += 1) {
|
|
987
|
+
if (values[i] - values[i - 1] !== interval) {
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (values[0] !== 0 && values.length < 5) {
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return {interval, last: values[values.length - 1], start: values[0]};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Whether an hour stride wraps the day cleanly from within its first interval
|
|
1000
|
+
// (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
|
|
1001
|
+
// stride has no distinct endpoint and keeps its bare or "ab" cadence. Every
|
|
1002
|
+
// other stride — a uneven interval, or one starting at or past its interval (a
|
|
1003
|
+
// bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
|
|
1004
|
+
function offsetCleanStride(
|
|
1005
|
+
stride: {start: number; interval: number}
|
|
1006
|
+
): boolean {
|
|
1007
|
+
return stride.start < stride.interval && 24 % stride.interval === 0;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// The hour field's stride, or null when the hour is not a cadence: a step
|
|
1011
|
+
// segment yields its {start, interval, last} directly; an all-single hour list
|
|
1012
|
+
// yields one only when its values form a step progression (so an irregular list
|
|
1013
|
+
// like 9,17 keeps enumerating). The IR is unchanged — the renderer recognizes
|
|
1014
|
+
// the stride and speaks it as a cadence, not the clock-time cross-product.
|
|
1015
|
+
function hourStride(
|
|
1016
|
+
ir: IR
|
|
1017
|
+
): {start: number; interval: number; last: number} | null {
|
|
1018
|
+
const segments = fieldSegments(ir, 'hour');
|
|
1019
|
+
|
|
1020
|
+
// A wildcard hour carries no segments (no discrete hours to stride over).
|
|
1021
|
+
if (!segments) {
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (segments.length === 1 && segments[0].kind === 'step') {
|
|
1026
|
+
const segment = segments[0];
|
|
1027
|
+
|
|
1028
|
+
// A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
|
|
1029
|
+
// value, not a stride: it has no interval to speak and no endpoint to pin.
|
|
1030
|
+
if (segment.fires.length < 2) {
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const start = segment.startToken === '*' ?
|
|
1035
|
+
0 :
|
|
1036
|
+
+segment.startToken.split('-')[0];
|
|
1037
|
+
|
|
1038
|
+
return {interval: segment.interval, last: segment.fires[
|
|
1039
|
+
segment.fires.length - 1], start};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const values = singleValues(segments);
|
|
1043
|
+
|
|
1044
|
+
return values && hourListStride(values);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// The bounded cadence for an hour stride that pins both clock-time endpoints,
|
|
1048
|
+
// or null when the hour is not such a stride. The core rewrites a uneven step
|
|
1049
|
+
// to its fire list, so a minute window/list/step crossed with it lands in the
|
|
1050
|
+
// enumerating list paths; there the bounded hour reads better as its cadence
|
|
1051
|
+
// ("…, alle 5 Stunden von 0 bis 20 Uhr") than as a wall of clock times. An
|
|
1052
|
+
// offset-clean stride keeps its existing confinement form, so only the
|
|
1053
|
+
// endpoint-bearing case routes here.
|
|
1054
|
+
function unevenHourCadence(ir: IR): string | null {
|
|
1055
|
+
const stride = hourStride(ir);
|
|
1056
|
+
|
|
1057
|
+
if (!stride || offsetCleanStride(stride)) {
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return hourStrideCadence(stride);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// The second's status against a pinned minute: a wildcard or sub-minute step
|
|
1065
|
+
// fills the minute (a "für eine Minute" frame at minute 0); a single 0 is just
|
|
1066
|
+
// the top of the minute (no clause); anything else needs its own clause.
|
|
1067
|
+
function subMinuteSecond(ir: IR): boolean {
|
|
1068
|
+
return ir.pattern.second === '*' || ir.shapes.second === 'step';
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// The lead clause for an hour-cadence rendering: the second and the pinned
|
|
1072
|
+
// minute, before the hour cadence. A pinned minute 0 folds in — a single,
|
|
1073
|
+
// list, or range second is counted "jeder Stunde" (the minute-0 is the top of
|
|
1074
|
+
// the hour), and a wildcard or sub-minute step second takes a "für eine
|
|
1075
|
+
// Minute" frame (the whole minute-0 window). A non-zero minute is a real clock
|
|
1076
|
+
// minute: the second leads with its own clause (if any), then the minute reads
|
|
1077
|
+
// "in Minute M".
|
|
1078
|
+
function hourCadenceLead(ir: IR, minute: number): string {
|
|
1079
|
+
if (minute === 0) {
|
|
1080
|
+
if (subMinuteSecond(ir)) {
|
|
1081
|
+
return secondsClause(ir, 'jeder Minute') + ' für eine Minute';
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return secondsClause(ir, 'jeder Stunde');
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const minutePhrase = 'in Minute ' + minute;
|
|
1088
|
+
|
|
1089
|
+
// A single 0 second is just the top of the minute, so the minute leads
|
|
1090
|
+
// alone; any other second prefixes its own clause.
|
|
1091
|
+
if (ir.pattern.second === '0') {
|
|
1092
|
+
return minutePhrase;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
return secondsClause(ir, 'jeder Minute') + ', ' + minutePhrase;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Render an hour step (or arithmetic-progression hour list) under a single
|
|
1099
|
+
// pinned minute and a second as a cadence — the lead clause, then the hour
|
|
1100
|
+
// cadence — instead of cross-multiplying the hours into a wall of clock times.
|
|
1101
|
+
// Returns null when the hour is not a stride (an irregular list, a single
|
|
1102
|
+
// hour, or a range), or when the cross-product is short enough that
|
|
1103
|
+
// enumeration is no longer than the cadence: a meaningful second makes every
|
|
1104
|
+
// clock time three digit-groups, so any stride is worth compacting; otherwise
|
|
1105
|
+
// the stride must exceed the clock-time cap, the same point at which the core
|
|
1106
|
+
// itself stops enumerating. The renderer returns the bare clause; the day
|
|
1107
|
+
// frame is composed in `describe`. Renderer-only; the IR is unchanged.
|
|
1108
|
+
function hourCadence(ir: IR, minute: number): string | null {
|
|
1109
|
+
const stride = hourStride(ir);
|
|
1110
|
+
|
|
1111
|
+
if (!stride) {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const fires = (stride.last - stride.start) / stride.interval + 1;
|
|
1116
|
+
|
|
1117
|
+
// A short stride that spells out as few clock times stays an enumeration only
|
|
1118
|
+
// when it wraps cleanly (an offset-clean stride with no endpoint): the bare
|
|
1119
|
+
// or "ab" form is no shorter than the list. A bounded or uneven stride has no
|
|
1120
|
+
// clean wrap, so its endpoint-pinning cadence ("alle 5 Stunden von 0 bis 20
|
|
1121
|
+
// Uhr") reads better however short.
|
|
1122
|
+
if (ir.pattern.second === '0' && fires <= maxClockTimes &&
|
|
1123
|
+
offsetCleanStride(stride)) {
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// A wildcard or sub-minute step second confined to minute 0 of a clean hour
|
|
1128
|
+
// stride is a confinement, not a juxtaposed cadence: it reads "für eine
|
|
1129
|
+
// Minute in jeder zweiten Stunde", reusing the every-Nth-hour idiom so the
|
|
1130
|
+
// minute-0 window is never heard as the bare hour cadence.
|
|
1131
|
+
const segment = fieldSegments(ir, 'hour')[0];
|
|
1132
|
+
const confined = minute === 0 && subMinuteSecond(ir) &&
|
|
1133
|
+
fieldSegments(ir, 'hour').length === 1 && segment.kind === 'step' &&
|
|
1134
|
+
confinedHourStride(segment);
|
|
1135
|
+
|
|
1136
|
+
if (confined) {
|
|
1137
|
+
return secondsClause(ir, 'jeder Minute') + ' für eine Minute ' +
|
|
1138
|
+
everyNthHour(segment);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
|
|
1142
|
+
// lead clause to fold in, so the bounded cadence stands on its own ("alle 5
|
|
1143
|
+
// Stunden von 0 bis 20 Uhr").
|
|
1144
|
+
if (minute === 0 && ir.pattern.second === '0') {
|
|
1145
|
+
return hourStrideCadence(stride);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return hourCadenceLead(ir, minute) + ', ' + hourStrideCadence(stride);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Whether an hour cadence or hour-range window applies to a plan with a single
|
|
1152
|
+
// pinned minute — the signal that the clause is a cadence/window, not a daily
|
|
1153
|
+
// clock-time list, so the "täglich" frame must not be added.
|
|
1154
|
+
function hourCadenceApplies(ir: IR): boolean {
|
|
1155
|
+
if (ir.shapes.minute !== 'single') {
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const minute = +ir.pattern.minute;
|
|
1160
|
+
|
|
1161
|
+
return hourCadence(ir, minute) !== null ||
|
|
1162
|
+
hourRangeCadence(ir, minute) !== null;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Whether the hour field is a range — or a list whose segments include a
|
|
1166
|
+
// range — and so forms a window rather than a cross-product of clock times.
|
|
1167
|
+
// A pure single-value list (9,17) has no range to span and still enumerates;
|
|
1168
|
+
// a step is handled by hourStride/hourCadence.
|
|
1169
|
+
function hasHourWindow(ir: IR): boolean {
|
|
1170
|
+
const segments = fieldSegments(ir, 'hour');
|
|
1171
|
+
|
|
1172
|
+
return !!segments && segments.some(function range(segment) {
|
|
1173
|
+
return segment.kind === 'range';
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Render an hour range (or a list whose segments include a range) under
|
|
1178
|
+
// minute 0 and a meaningful second as the hour-range window — the lead clause,
|
|
1179
|
+
// then "von 9 bis 17 Uhr" (and any non-contiguous hour as "und um 22 Uhr") —
|
|
1180
|
+
// instead of cross-multiplying the hours into a wall of clock times. The
|
|
1181
|
+
// hour-RANGE analog of hourCadence; returns the bare clause (the day frame is
|
|
1182
|
+
// suppressed by hourCadenceApplies). Returns null when the hour has no range,
|
|
1183
|
+
// when the minute is non-zero (a real clock minute the existing window form
|
|
1184
|
+
// already speaks), or when a plain :00 set carries no clause. Renderer-only;
|
|
1185
|
+
// the IR is unchanged.
|
|
1186
|
+
function hourRangeCadence(ir: IR, minute: number): string | null {
|
|
1187
|
+
if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
return hourCadenceLead(ir, minute) + ', ' + hourRangeWindowTail(ir);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// The hour-range window as a cadence tail at the top of each hour: each range
|
|
1195
|
+
// segment is "von X bis Y Uhr", any non-contiguous hour is "um Z Uhr", joined
|
|
1196
|
+
// — the same parts the bare "stündlich von 9 bis 17 Uhr" window forms, minus
|
|
1197
|
+
// the "stündlich" prefix the lead replaces. The minute has folded into the
|
|
1198
|
+
// lead, so the parts close on the top of their final hour.
|
|
1199
|
+
function hourRangeWindowTail(ir: IR): string {
|
|
1200
|
+
// Minute 0 with a falsy second renders each part as a bare hour ("von 9 bis
|
|
1201
|
+
// 17 Uhr", "um 22 Uhr"); the separator is unused in that path.
|
|
1202
|
+
return joinList(hourSegmentParts(ir, 0, 0, ':'));
|
|
1203
|
+
}
|
|
1204
|
+
|
|
699
1205
|
// An hourly window: "stündlich von 9 bis 17 Uhr", or every minute across it.
|
|
700
1206
|
function renderHourRange(
|
|
701
1207
|
ir: IR,
|
|
@@ -716,9 +1222,15 @@ function renderHourRange(
|
|
|
716
1222
|
return 'stündlich ' + window;
|
|
717
1223
|
}
|
|
718
1224
|
|
|
719
|
-
// A non-zero single minute ('lead') or a minute range leads the window.
|
|
720
|
-
|
|
721
|
-
|
|
1225
|
+
// A non-zero single minute ('lead') or a minute range leads the window. A
|
|
1226
|
+
// non-uniform minute step the core enumerated to a fire list reads as its
|
|
1227
|
+
// bounded cadence ("alle 2 Minuten von Minute 3 bis 59 jeder Stunde") instead
|
|
1228
|
+
// of the wall of fires; an irregular list or a single minute keeps the
|
|
1229
|
+
// counted form.
|
|
1230
|
+
return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute,
|
|
1231
|
+
'jeder Stunde') ??
|
|
1232
|
+
countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde') +
|
|
1233
|
+
', ' + window;
|
|
722
1234
|
}
|
|
723
1235
|
|
|
724
1236
|
// One or more clock times: "um 9 Uhr", "um 14:30 Uhr", "um 9 und 17 Uhr".
|
|
@@ -727,6 +1239,18 @@ function renderClockTimes(
|
|
|
727
1239
|
plan: Extract<PlanNode, {kind: 'clockTimes'}>,
|
|
728
1240
|
opts: Opts
|
|
729
1241
|
): string {
|
|
1242
|
+
// An hour step or range (or arithmetic-progression hour list) under a single
|
|
1243
|
+
// pinned minute reads as a cadence or window rather than a cross-product of
|
|
1244
|
+
// clock times.
|
|
1245
|
+
if (ir.shapes.minute === 'single') {
|
|
1246
|
+
const minute = +ir.pattern.minute;
|
|
1247
|
+
const cadence = hourCadence(ir, minute) ?? hourRangeCadence(ir, minute);
|
|
1248
|
+
|
|
1249
|
+
if (cadence !== null) {
|
|
1250
|
+
return cadence;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
730
1254
|
return 'um ' + timesPhrase(plan.times, opts.style.sep);
|
|
731
1255
|
}
|
|
732
1256
|
|
|
@@ -808,6 +1332,13 @@ function isComposeMinuteZero(ir: IR): boolean {
|
|
|
808
1332
|
// hour step (rendered as its fire list "um 0, 5, … Uhr", not the cadence "alle
|
|
809
1333
|
// N Stunden"). A frequency clause already implies recurrence.
|
|
810
1334
|
function needsDailyFrame(ir: IR): boolean {
|
|
1335
|
+
// An hour cadence is a sub-daily frequency, not a daily clock-time list, so
|
|
1336
|
+
// it must not take the "täglich" frame ("alle 2 Stunden", not "täglich alle
|
|
1337
|
+
// 2 Stunden").
|
|
1338
|
+
if (hourCadenceApplies(ir)) {
|
|
1339
|
+
return false;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
811
1342
|
if (ir.plan.kind === 'clockTimes' || isComposeMinuteZero(ir)) {
|
|
812
1343
|
return true;
|
|
813
1344
|
}
|