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.
- package/CHANGELOG.md +89 -0
- package/cli.js +9 -0
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +280 -64
- package/dist/cronli5.js +280 -64
- package/dist/lang/de.cjs +227 -42
- package/dist/lang/de.js +227 -42
- package/dist/lang/en.cjs +216 -50
- package/dist/lang/en.js +216 -50
- package/dist/lang/es.cjs +259 -54
- package/dist/lang/es.js +259 -54
- package/dist/lang/fi.cjs +230 -69
- package/dist/lang/fi.js +230 -69
- package/dist/lang/zh.cjs +190 -19
- package/dist/lang/zh.js +190 -19
- package/package.json +3 -1
- package/src/core/analyze.ts +7 -0
- package/src/core/ir.ts +1 -1
- package/src/core/normalize.ts +94 -4
- package/src/core/util.ts +31 -1
- package/src/lang/de/index.ts +449 -46
- package/src/lang/en/index.ts +433 -63
- package/src/lang/es/index.ts +505 -63
- package/src/lang/fi/index.ts +455 -89
- package/src/lang/zh/index.ts +393 -30
- package/types/core/ir.d.ts +1 -1
- package/types/core/util.d.ts +6 -1
package/src/lang/de/index.ts
CHANGED
|
@@ -2,6 +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 {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
|
|
6
|
+
import {arithmeticStep, toFieldNumber} from '../../core/util.js';
|
|
5
7
|
import type {Cronli5Options} from '../../types.js';
|
|
6
8
|
import type {
|
|
7
9
|
Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
|
|
@@ -20,6 +22,19 @@ interface Unit {
|
|
|
20
22
|
singular: string;
|
|
21
23
|
}
|
|
22
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
|
+
|
|
23
38
|
const UNITS: Record<'second' | 'minute' | 'hour', Unit> = {
|
|
24
39
|
hour: {every: 'jede', plural: 'Stunden', singular: 'Stunde'},
|
|
25
40
|
minute: {every: 'jede', plural: 'Minuten', singular: 'Minute'},
|
|
@@ -49,6 +64,98 @@ function cleanStep(segment: StepSegment, cycle: number): boolean {
|
|
|
49
64
|
cycle % segment.interval === 0;
|
|
50
65
|
}
|
|
51
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
|
+
|
|
52
159
|
type NameToken = string | number;
|
|
53
160
|
type NameSegment =
|
|
54
161
|
| {kind: 'single'; value: NameToken}
|
|
@@ -61,11 +168,6 @@ const weekdayNames = [
|
|
|
61
168
|
'freitags', 'samstags'
|
|
62
169
|
];
|
|
63
170
|
|
|
64
|
-
// Cron weekday tokens (part of cron syntax), mapped to indices.
|
|
65
|
-
const weekdayTokens: {[token: string]: number} = {
|
|
66
|
-
SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6
|
|
67
|
-
};
|
|
68
|
-
|
|
69
171
|
function fieldSegments(ir: IR, field: Field): Segment[] {
|
|
70
172
|
return ir.analyses.segments[field] as Segment[];
|
|
71
173
|
}
|
|
@@ -94,14 +196,9 @@ function joinList(items: string[]): string {
|
|
|
94
196
|
return items.slice(0, -1).join(', ') + ' und ' + items[items.length - 1];
|
|
95
197
|
}
|
|
96
198
|
|
|
97
|
-
// The adverbial name for a weekday
|
|
199
|
+
// The adverbial name for a canonical weekday number (0 = Sunday).
|
|
98
200
|
function weekdayName(token: NameToken): string {
|
|
99
|
-
|
|
100
|
-
return weekdayNames[0];
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return weekdayNames[token as number] ||
|
|
104
|
-
weekdayNames[weekdayTokens[token as string]];
|
|
201
|
+
return weekdayNames[+token];
|
|
105
202
|
}
|
|
106
203
|
|
|
107
204
|
// "montags bis freitags".
|
|
@@ -150,12 +247,24 @@ function everyNthHour(segment: StepSegment): string {
|
|
|
150
247
|
return start === 0 ? base : base + ' ab ' + start + ' Uhr';
|
|
151
248
|
}
|
|
152
249
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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;
|
|
156
257
|
}
|
|
157
258
|
|
|
158
|
-
|
|
259
|
+
const start = segment.startToken === '*' ? 0 : +segment.startToken;
|
|
260
|
+
|
|
261
|
+
return 24 % segment.interval === 0 && start < segment.interval;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The Quartz weekday stem (`5L`, `MON#2`) is not number-canonicalized in the
|
|
265
|
+
// core, so it may still be a name token; resolve it via the core's index.
|
|
266
|
+
function weekdayNoun(token: string): string {
|
|
267
|
+
return weekdayNouns[toFieldNumber(token, weekdayNumbers)];
|
|
159
268
|
}
|
|
160
269
|
|
|
161
270
|
// The Quartz weekday phrase: "am letzten Freitag des Monats", "am zweiten
|
|
@@ -193,18 +302,12 @@ function quartzDate(field: string): string | null {
|
|
|
193
302
|
return null;
|
|
194
303
|
}
|
|
195
304
|
|
|
196
|
-
// Cron month tokens (part of cron syntax), mapped to indices. The month names
|
|
197
|
-
// themselves are dialect-scoped and resolved from `opts.style.months`.
|
|
198
|
-
const monthTokens: {[token: string]: number} = {
|
|
199
|
-
JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
|
|
200
|
-
JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12
|
|
201
|
-
};
|
|
202
|
-
|
|
203
305
|
type Months = GermanStyle['months'];
|
|
204
306
|
|
|
307
|
+
// The month names are dialect-scoped (resolved from `opts.style.months`);
|
|
308
|
+
// the canonical month number indexes them.
|
|
205
309
|
function monthName(token: NameToken, months: Months): string {
|
|
206
|
-
return
|
|
207
|
-
months[monthTokens[token as string]]) as string;
|
|
310
|
+
return months[+token] as string;
|
|
208
311
|
}
|
|
209
312
|
|
|
210
313
|
// "von Juni bis August".
|
|
@@ -360,17 +463,30 @@ function countedPhrase(
|
|
|
360
463
|
// The seconds clause: "alle 30 Sekunden" for a step, else "in Sekunde 15
|
|
361
464
|
// jeder Minute".
|
|
362
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 {
|
|
363
475
|
if (ir.pattern.second === '*') {
|
|
364
476
|
return 'jede Sekunde';
|
|
365
477
|
}
|
|
366
478
|
|
|
367
479
|
const segments = ir.analyses.segments.second;
|
|
368
480
|
|
|
369
|
-
|
|
370
|
-
|
|
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);
|
|
371
486
|
}
|
|
372
487
|
|
|
373
|
-
return
|
|
488
|
+
return strideFromSegments(segments as Segment[], UNITS.second, anchor) ??
|
|
489
|
+
countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ' ' + anchor;
|
|
374
490
|
}
|
|
375
491
|
|
|
376
492
|
// A clock time that always shows its minutes: "9:00", "9:30".
|
|
@@ -492,9 +608,17 @@ function renderSeconds(ir: IR): string {
|
|
|
492
608
|
}
|
|
493
609
|
|
|
494
610
|
// The minute-past-the-hour clause: "in Minute 5 jeder Stunde", "in den
|
|
495
|
-
// 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
|
+
|
|
496
620
|
function renderMinutePast(ir: IR): string {
|
|
497
|
-
return
|
|
621
|
+
return minutePastClause(ir);
|
|
498
622
|
}
|
|
499
623
|
|
|
500
624
|
// A specific minute and second: "in Minute 0 und Sekunde 30 jeder Stunde".
|
|
@@ -511,12 +635,33 @@ function renderSecondsWithinMinute(
|
|
|
511
635
|
' jeder Stunde';
|
|
512
636
|
}
|
|
513
637
|
|
|
514
|
-
//
|
|
638
|
+
// The whole-hour noun in the genitive: "der Mitternachtsstunde" (0), "der
|
|
639
|
+
// Mittagsstunde" (12), or "der <H>-Uhr-Stunde" for any other hour.
|
|
640
|
+
function wholeHour(hour: number): string {
|
|
641
|
+
if (hour === 0) {
|
|
642
|
+
return 'der Mitternachtsstunde';
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (hour === 12) {
|
|
646
|
+
return 'der Mittagsstunde';
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return 'der ' + hour + '-Uhr-Stunde';
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// A minute span inside one hour: "jede Minute von 9:00 bis 9:30 Uhr". A
|
|
653
|
+
// wildcard minute is the whole hour, so it reads as that hour itself ("jede
|
|
654
|
+
// Minute der 9-Uhr-Stunde") rather than a synthesized "von 9:00 bis 9:59"
|
|
655
|
+
// range the source never stated; a plain range is a real window and keeps it.
|
|
515
656
|
function renderMinuteSpanInHour(
|
|
516
657
|
ir: IR,
|
|
517
658
|
plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
|
|
518
659
|
opts: Opts
|
|
519
660
|
): string {
|
|
661
|
+
if (ir.pattern.minute === '*') {
|
|
662
|
+
return 'jede Minute ' + wholeHour(plan.hour);
|
|
663
|
+
}
|
|
664
|
+
|
|
520
665
|
const sep = opts.style.sep;
|
|
521
666
|
|
|
522
667
|
return 'jede Minute von ' + spanTime(plan.hour, plan.span[0], sep) +
|
|
@@ -530,9 +675,75 @@ function renderComposeSeconds(
|
|
|
530
675
|
plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
|
|
531
676
|
opts: Opts
|
|
532
677
|
): string {
|
|
678
|
+
// An hour step (or arithmetic-progression hour list) under a single pinned
|
|
679
|
+
// minute is a cadence, not a wall of clock times: the second/minute lead,
|
|
680
|
+
// then the hour cadence ("in Sekunde 30 jeder Stunde, alle 2 Stunden"). The
|
|
681
|
+
// clock-time rest would otherwise cross-multiply the hours.
|
|
682
|
+
if ((plan.rest.kind === 'clockTimes' ||
|
|
683
|
+
plan.rest.kind === 'compactClockTimes') &&
|
|
684
|
+
ir.shapes.minute === 'single') {
|
|
685
|
+
const cadence = hourCadence(ir, +ir.pattern.minute);
|
|
686
|
+
|
|
687
|
+
if (cadence !== null) {
|
|
688
|
+
return cadence;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// A sub-minute second with the minute pinned to 0 and a specific hour: the
|
|
693
|
+
// clock-time rest would read "um 9 Uhr", which hides the pinned :00 (and so
|
|
694
|
+
// the one-minute confinement — 60 fires in :00, not 3,600 across the hour).
|
|
695
|
+
// Bind the seconds into the explicit clock minute in the genitive ("der
|
|
696
|
+
// Minute 9:00"); the recurring "täglich"/day frame is added in `describe`.
|
|
697
|
+
if (composeMinuteZero(ir, plan)) {
|
|
698
|
+
return secondsLead(ir) + ' ' +
|
|
699
|
+
clockMinuteGenitive(plan.rest.times, opts.style.sep);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// A wildcard second under a minute */2 with a wildcard hour juxtaposes two
|
|
703
|
+
// cadences that read as contradictory ("jede Sekunde, alle 2 Minuten"). Bind
|
|
704
|
+
// them in the genitive ("jede Sekunde jeder zweiten Minute"), mirroring
|
|
705
|
+
// English. Other strides, a restricted hour, and an hour cadence keep the
|
|
706
|
+
// juxtaposed form.
|
|
707
|
+
if (plan.rest.kind === 'minuteFrequency' &&
|
|
708
|
+
ir.shapes.second === 'wildcard' && ir.shapes.hour === 'wildcard') {
|
|
709
|
+
const minuteStep = stepSegment(ir.analyses.segments.minute);
|
|
710
|
+
|
|
711
|
+
if (minuteStep.startToken === '*' && minuteStep.interval === 2) {
|
|
712
|
+
return secondsLead(ir) + ' jeder zweiten Minute';
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
533
716
|
return secondsLead(ir) + ', ' + render(ir, plan.rest, opts);
|
|
534
717
|
}
|
|
535
718
|
|
|
719
|
+
// True when a compose-seconds plan is a sub-minute second over a minute-0
|
|
720
|
+
// clock-time rest — the case that reads as the bare hour and so must surface
|
|
721
|
+
// the pinned clock minute.
|
|
722
|
+
function composeMinuteZero(
|
|
723
|
+
ir: IR,
|
|
724
|
+
plan: Extract<PlanNode, {kind: 'composeSeconds'}>
|
|
725
|
+
): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
|
|
726
|
+
{rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
|
|
727
|
+
return plan.rest.kind === 'clockTimes' &&
|
|
728
|
+
plan.rest.times.every((time) => +time.minute === 0);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// The pinned clock minute in the genitive: "der Minute 9:00" for one hour,
|
|
732
|
+
// "der Minuten 9:00, 10:00 und 17:00" for several — the explicit ":00" so the
|
|
733
|
+
// minute-0 confinement stays visible.
|
|
734
|
+
function clockMinuteGenitive(
|
|
735
|
+
times: {hour: number; minute: number}[],
|
|
736
|
+
sep: string
|
|
737
|
+
): string {
|
|
738
|
+
const clocks = times.map(function clock(time): string {
|
|
739
|
+
return time.hour + sep + pad(time.minute);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
return clocks.length === 1 ?
|
|
743
|
+
'der Minute ' + clocks[0] :
|
|
744
|
+
'der Minuten ' + joinList(clocks);
|
|
745
|
+
}
|
|
746
|
+
|
|
536
747
|
// A minute clause across discrete hours: "in den Minuten 0 bis 30, um 9 und
|
|
537
748
|
// 17 Uhr".
|
|
538
749
|
function renderMinutesAcrossHours(
|
|
@@ -551,13 +762,14 @@ function renderMinutesAcrossHours(
|
|
|
551
762
|
atHours(plan.times.fires) :
|
|
552
763
|
joinList(hourSegmentParts(ir, 0, 0, sep));
|
|
553
764
|
|
|
554
|
-
return
|
|
765
|
+
return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
|
|
766
|
+
countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
|
|
555
767
|
}
|
|
556
768
|
|
|
557
769
|
// A minute clause across a stepped hour range. A wildcard minute (a cadence)
|
|
558
770
|
// is reached only for a clean step and is confined to every Nth hour ("jede
|
|
559
|
-
// Minute in jeder zweiten Stunde"); a
|
|
560
|
-
//
|
|
771
|
+
// Minute in jeder zweiten Stunde"); a range or list leads with its minutes and
|
|
772
|
+
// trails the same cadence ("in den Minuten 0 bis 30, in jeder zweiten Stunde").
|
|
561
773
|
function renderMinuteSpanAcrossHourStep(
|
|
562
774
|
ir: IR,
|
|
563
775
|
plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>
|
|
@@ -567,8 +779,17 @@ function renderMinuteSpanAcrossHourStep(
|
|
|
567
779
|
everyNthHour(stepSegment(ir.analyses.segments.hour));
|
|
568
780
|
}
|
|
569
781
|
|
|
570
|
-
|
|
571
|
-
|
|
782
|
+
// The minute (range or list) leads; the hour trails. A clean stride confines
|
|
783
|
+
// to "in jeder N-ten Stunde" — the same cadence the wildcard form and the
|
|
784
|
+
// minute-step compositions use, never a juxtaposed second frequency. A
|
|
785
|
+
// bounded stride (reachable only via a range) enumerates its fires instead.
|
|
786
|
+
const segment = stepSegment(ir.analyses.segments.hour);
|
|
787
|
+
const hours = confinedHourStride(segment) ?
|
|
788
|
+
everyNthHour(segment) :
|
|
789
|
+
atHours(segment.fires);
|
|
790
|
+
|
|
791
|
+
return (strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
|
|
792
|
+
countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
|
|
572
793
|
}
|
|
573
794
|
|
|
574
795
|
// Compact minutes across discrete hours: "in den Minuten 5 und 10, um 9, 17,
|
|
@@ -584,6 +805,15 @@ function renderCompactClockTimes(
|
|
|
584
805
|
const sep = opts.style.sep;
|
|
585
806
|
|
|
586
807
|
if (plan.fold) {
|
|
808
|
+
// An hour step (or arithmetic-progression hour list) under the single
|
|
809
|
+
// pinned minute reads as a cadence, not a wall of clock times. (Returns
|
|
810
|
+
// null for an irregular list or a range, which keep folding below.)
|
|
811
|
+
const cadence = hourCadence(ir, plan.minute);
|
|
812
|
+
|
|
813
|
+
if (cadence !== null) {
|
|
814
|
+
return cadence;
|
|
815
|
+
}
|
|
816
|
+
|
|
587
817
|
const hourly = fieldSegments(ir, 'hour')
|
|
588
818
|
.some((segment) => segment.kind === 'range');
|
|
589
819
|
|
|
@@ -604,7 +834,8 @@ function renderCompactClockTimes(
|
|
|
604
834
|
countedPhrase(ir, 'second', 'Sekunde', 'Sekunden') + ', ' : '';
|
|
605
835
|
|
|
606
836
|
return lead +
|
|
607
|
-
|
|
837
|
+
(strideFromSegments(fieldSegments(ir, 'minute'), UNITS.minute, '') ??
|
|
838
|
+
countedPhrase(ir, 'minute', 'Minute', 'Minuten')) + ', ' + hours;
|
|
608
839
|
}
|
|
609
840
|
|
|
610
841
|
// A repeating minute step, optionally within an hour window: "alle 5
|
|
@@ -617,9 +848,7 @@ function renderMinuteFrequency(
|
|
|
617
848
|
const segment = stepSegment(ir.analyses.segments.minute);
|
|
618
849
|
const sep = opts.style.sep;
|
|
619
850
|
const clean = cleanStep(segment, 60);
|
|
620
|
-
const base =
|
|
621
|
-
everyN(segment.interval, UNITS.minute) :
|
|
622
|
-
countedPhrase(ir, 'minute', 'Minute', 'Minuten') + ' jeder Stunde';
|
|
851
|
+
const base = stepClause(segment, UNITS.minute, 'jeder Stunde');
|
|
623
852
|
|
|
624
853
|
if (plan.hours.kind === 'window') {
|
|
625
854
|
const window = hourWindow(plan.hours.from, plan.hours.to, plan.hours.last,
|
|
@@ -653,6 +882,147 @@ function hourStepPhrase(ir: IR): string {
|
|
|
653
882
|
atHours(segment.fires);
|
|
654
883
|
}
|
|
655
884
|
|
|
885
|
+
// --- Hour-step cadence (the 24-cycle analog of renderStride). ---
|
|
886
|
+
|
|
887
|
+
// Speak an hour stride as a cadence with clock-time bounds: a clean stride
|
|
888
|
+
// from midnight is the bare cadence ("alle 2 Stunden"); a clean offset names
|
|
889
|
+
// only its start ("alle 6 Stunden ab 2 Uhr"); a bounded or non-tiling stride
|
|
890
|
+
// pins both clock-time endpoints ("alle 2 Stunden von 9 bis 17 Uhr") so the
|
|
891
|
+
// bounded set reads unambiguously. Used wherever an hour step (or
|
|
892
|
+
// arithmetic-progression hour list) would otherwise be cross-multiplied into a
|
|
893
|
+
// wall of clock times.
|
|
894
|
+
function hourStrideCadence(
|
|
895
|
+
stride: {start: number; interval: number; last: number}
|
|
896
|
+
): string {
|
|
897
|
+
const {start, interval, last} = stride;
|
|
898
|
+
const cadence = everyN(interval, UNITS.hour);
|
|
899
|
+
const tiles = 24 % interval === 0;
|
|
900
|
+
|
|
901
|
+
if (start === 0 && tiles) {
|
|
902
|
+
return cadence;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (start < interval && tiles) {
|
|
906
|
+
return cadence + ' ab ' + start + ' Uhr';
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return cadence + ' von ' + start + ' bis ' + last + ' Uhr';
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// The hour field's stride, or null when the hour is not a cadence: a step
|
|
913
|
+
// segment yields its {start, interval, last} directly; an all-single hour list
|
|
914
|
+
// yields one only when its values form a long-enough arithmetic progression
|
|
915
|
+
// (so an irregular list like 9,17 keeps enumerating). The IR is unchanged —
|
|
916
|
+
// the renderer recognizes the stride and speaks it as a cadence instead of the
|
|
917
|
+
// clock-time cross-product.
|
|
918
|
+
function hourStride(
|
|
919
|
+
ir: IR
|
|
920
|
+
): {start: number; interval: number; last: number} | null {
|
|
921
|
+
const segments = fieldSegments(ir, 'hour');
|
|
922
|
+
|
|
923
|
+
// A wildcard hour carries no segments (no discrete hours to stride over).
|
|
924
|
+
if (!segments) {
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (segments.length === 1 && segments[0].kind === 'step') {
|
|
929
|
+
const segment = segments[0];
|
|
930
|
+
const start = segment.startToken === '*' ?
|
|
931
|
+
0 :
|
|
932
|
+
+segment.startToken.split('-')[0];
|
|
933
|
+
|
|
934
|
+
return {interval: segment.interval, last: segment.fires[
|
|
935
|
+
segment.fires.length - 1], start};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const values = singleValues(segments);
|
|
939
|
+
const step = values && arithmeticStep(values);
|
|
940
|
+
|
|
941
|
+
return step || null;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// The second's status against a pinned minute: a wildcard or sub-minute step
|
|
945
|
+
// fills the minute (a "für eine Minute" frame at minute 0); a single 0 is just
|
|
946
|
+
// the top of the minute (no clause); anything else needs its own clause.
|
|
947
|
+
function subMinuteSecond(ir: IR): boolean {
|
|
948
|
+
return ir.pattern.second === '*' || ir.shapes.second === 'step';
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// The lead clause for an hour-cadence rendering: the second and the pinned
|
|
952
|
+
// minute, before the hour cadence. A pinned minute 0 folds in — a single,
|
|
953
|
+
// list, or range second is counted "jeder Stunde" (the minute-0 is the top of
|
|
954
|
+
// the hour), and a wildcard or sub-minute step second takes a "für eine
|
|
955
|
+
// Minute" frame (the whole minute-0 window). A non-zero minute is a real clock
|
|
956
|
+
// minute: the second leads with its own clause (if any), then the minute reads
|
|
957
|
+
// "in Minute M".
|
|
958
|
+
function hourCadenceLead(ir: IR, minute: number): string {
|
|
959
|
+
if (minute === 0) {
|
|
960
|
+
if (subMinuteSecond(ir)) {
|
|
961
|
+
return secondsClause(ir, 'jeder Minute') + ' für eine Minute';
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return secondsClause(ir, 'jeder Stunde');
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const minutePhrase = 'in Minute ' + minute;
|
|
968
|
+
|
|
969
|
+
// A single 0 second is just the top of the minute, so the minute leads
|
|
970
|
+
// alone; any other second prefixes its own clause.
|
|
971
|
+
if (ir.pattern.second === '0') {
|
|
972
|
+
return minutePhrase;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return secondsClause(ir, 'jeder Minute') + ', ' + minutePhrase;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Render an hour step (or arithmetic-progression hour list) under a single
|
|
979
|
+
// pinned minute and a second as a cadence — the lead clause, then the hour
|
|
980
|
+
// cadence — instead of cross-multiplying the hours into a wall of clock times.
|
|
981
|
+
// Returns null when the hour is not a stride (an irregular list, a single
|
|
982
|
+
// hour, or a range), or when the cross-product is short enough that
|
|
983
|
+
// enumeration is no longer than the cadence: a meaningful second makes every
|
|
984
|
+
// clock time three digit-groups, so any stride is worth compacting; otherwise
|
|
985
|
+
// the stride must exceed the clock-time cap, the same point at which the core
|
|
986
|
+
// itself stops enumerating. The renderer returns the bare clause; the day
|
|
987
|
+
// frame is composed in `describe`. Renderer-only; the IR is unchanged.
|
|
988
|
+
function hourCadence(ir: IR, minute: number): string | null {
|
|
989
|
+
const stride = hourStride(ir);
|
|
990
|
+
|
|
991
|
+
if (!stride) {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const fires = (stride.last - stride.start) / stride.interval + 1;
|
|
996
|
+
|
|
997
|
+
if (ir.pattern.second === '0' && fires <= maxClockTimes) {
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// A wildcard or sub-minute step second confined to minute 0 of a clean hour
|
|
1002
|
+
// stride is a confinement, not a juxtaposed cadence: it reads "für eine
|
|
1003
|
+
// Minute in jeder zweiten Stunde", reusing the every-Nth-hour idiom so the
|
|
1004
|
+
// minute-0 window is never heard as the bare hour cadence.
|
|
1005
|
+
const segment = fieldSegments(ir, 'hour')[0];
|
|
1006
|
+
const confined = minute === 0 && subMinuteSecond(ir) &&
|
|
1007
|
+
fieldSegments(ir, 'hour').length === 1 && segment.kind === 'step' &&
|
|
1008
|
+
confinedHourStride(segment);
|
|
1009
|
+
|
|
1010
|
+
if (confined) {
|
|
1011
|
+
return secondsClause(ir, 'jeder Minute') + ' für eine Minute ' +
|
|
1012
|
+
everyNthHour(segment);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
return hourCadenceLead(ir, minute) + ', ' + hourStrideCadence(stride);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Whether an hour cadence applies to a plan with a single pinned minute — the
|
|
1019
|
+
// signal that the clause is a cadence, not a daily clock-time list, so the
|
|
1020
|
+
// "täglich" frame must not be added.
|
|
1021
|
+
function hourCadenceApplies(ir: IR): boolean {
|
|
1022
|
+
return ir.shapes.minute === 'single' &&
|
|
1023
|
+
hourCadence(ir, +ir.pattern.minute) !== null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
656
1026
|
// An hourly window: "stündlich von 9 bis 17 Uhr", or every minute across it.
|
|
657
1027
|
function renderHourRange(
|
|
658
1028
|
ir: IR,
|
|
@@ -684,6 +1054,16 @@ function renderClockTimes(
|
|
|
684
1054
|
plan: Extract<PlanNode, {kind: 'clockTimes'}>,
|
|
685
1055
|
opts: Opts
|
|
686
1056
|
): string {
|
|
1057
|
+
// An hour step (or arithmetic-progression hour list) under a single pinned
|
|
1058
|
+
// minute reads as a cadence rather than a cross-product of clock times.
|
|
1059
|
+
if (ir.shapes.minute === 'single') {
|
|
1060
|
+
const cadence = hourCadence(ir, +ir.pattern.minute);
|
|
1061
|
+
|
|
1062
|
+
if (cadence !== null) {
|
|
1063
|
+
return cadence;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
687
1067
|
return 'um ' + timesPhrase(plan.times, opts.style.sep);
|
|
688
1068
|
}
|
|
689
1069
|
|
|
@@ -741,15 +1121,38 @@ function qualifier(ir: IR, months: Months): string {
|
|
|
741
1121
|
}
|
|
742
1122
|
|
|
743
1123
|
// Plan kinds whose clause is a clock time: the qualifier leads them ("montags
|
|
744
|
-
// um 9 Uhr"); a frequency clause trails it ("jede Minute montags").
|
|
1124
|
+
// um 9 Uhr"); a frequency clause trails it ("jede Minute montags"). The
|
|
1125
|
+
// minute-0 compose-seconds clause is anchored on a clock minute too, so the
|
|
1126
|
+
// qualifier leads it ("montags jede Sekunde der Minute 9:00").
|
|
745
1127
|
const LEADING_PLANS = new Set(['clockTimes']);
|
|
746
1128
|
|
|
1129
|
+
// True when the leading qualifier should precede the clause: a clock-time
|
|
1130
|
+
// plan, or the minute-0 compose-seconds clause that surfaces a clock minute.
|
|
1131
|
+
function leadsQualifier(ir: IR): boolean {
|
|
1132
|
+
return LEADING_PLANS.has(ir.plan.kind) || isComposeMinuteZero(ir);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Whether the planned clause is the minute-0 compose-seconds confinement
|
|
1136
|
+
// (a sub-minute second over a minute-0 clock-time rest).
|
|
1137
|
+
function isComposeMinuteZero(ir: IR): boolean {
|
|
1138
|
+
return ir.plan.kind === 'composeSeconds' &&
|
|
1139
|
+
composeMinuteZero(ir, ir.plan);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
747
1142
|
// True when the clause is a bare daily clock-time list and so needs the
|
|
748
|
-
// "täglich" frame to read as recurring, not a one-off: clockTimes always,
|
|
749
|
-
//
|
|
750
|
-
//
|
|
1143
|
+
// "täglich" frame to read as recurring, not a one-off: clockTimes always, the
|
|
1144
|
+
// minute-0 compose-seconds clause (a recurring clock minute), and an uneven
|
|
1145
|
+
// hour step (rendered as its fire list "um 0, 5, … Uhr", not the cadence "alle
|
|
1146
|
+
// N Stunden"). A frequency clause already implies recurrence.
|
|
751
1147
|
function needsDailyFrame(ir: IR): boolean {
|
|
752
|
-
|
|
1148
|
+
// An hour cadence is a sub-daily frequency, not a daily clock-time list, so
|
|
1149
|
+
// it must not take the "täglich" frame ("alle 2 Stunden", not "täglich alle
|
|
1150
|
+
// 2 Stunden").
|
|
1151
|
+
if (hourCadenceApplies(ir)) {
|
|
1152
|
+
return false;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (ir.plan.kind === 'clockTimes' || isComposeMinuteZero(ir)) {
|
|
753
1156
|
return true;
|
|
754
1157
|
}
|
|
755
1158
|
|
|
@@ -804,7 +1207,7 @@ function describe(ir: IR, opts: Opts): string {
|
|
|
804
1207
|
let base = core;
|
|
805
1208
|
|
|
806
1209
|
if (qual) {
|
|
807
|
-
base =
|
|
1210
|
+
base = leadsQualifier(ir) ?
|
|
808
1211
|
qual + ' ' + core :
|
|
809
1212
|
core + ' ' + qual;
|
|
810
1213
|
}
|