cronli5 0.1.1 → 0.1.4
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 +91 -0
- package/cli.js +9 -0
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +127 -38
- package/dist/cronli5.js +127 -38
- package/dist/lang/de.cjs +59 -36
- package/dist/lang/de.js +59 -36
- package/dist/lang/en.cjs +43 -32
- package/dist/lang/en.js +43 -32
- package/dist/lang/es.cjs +81 -40
- package/dist/lang/es.js +81 -40
- package/dist/lang/fi.cjs +46 -44
- package/dist/lang/fi.js +46 -44
- package/dist/lang/zh.cjs +36 -13
- package/dist/lang/zh.js +36 -13
- package/package.json +2 -1
- package/src/core/normalize.ts +144 -6
- package/src/lang/de/index.ts +99 -35
- package/src/lang/en/index.ts +113 -48
- package/src/lang/es/index.ts +140 -41
- package/src/lang/fi/index.ts +62 -39
- package/src/lang/zh/index.ts +93 -21
package/dist/lang/zh.js
CHANGED
|
@@ -171,10 +171,12 @@ function hourFrame(ir) {
|
|
|
171
171
|
return "\u5728" + hourList(ir) + "\uFF0C";
|
|
172
172
|
}
|
|
173
173
|
function renderMinuteFrequency(ir, plan) {
|
|
174
|
-
const
|
|
174
|
+
const minuteStep = stepSegment(ir, "minute");
|
|
175
|
+
const base = minuteStep.startToken === "*" ? cadence(minuteStep.interval, UNITS.minute) : renderMinutePast(ir);
|
|
175
176
|
const { hours } = plan;
|
|
176
177
|
if (hours.kind === "step") {
|
|
177
|
-
|
|
178
|
+
const hourStep = stepSegment(ir, "hour");
|
|
179
|
+
return hourStep.startToken === "*" ? cadence(hourStep.interval, UNITS.hour) + base : "\u5728" + hourList(ir) + "\uFF0C" + base;
|
|
178
180
|
}
|
|
179
181
|
if (hours.kind === "single" || hours.kind === "window" && hours.from === hours.to) {
|
|
180
182
|
return "\u5728" + hourWord(hours.from) + "\u81F3" + hours.from + "\u70B9" + hours.last + "\u5206\u4E4B\u95F4\uFF0C" + base;
|
|
@@ -189,6 +191,9 @@ function renderMinuteFrequency(ir, plan) {
|
|
|
189
191
|
}
|
|
190
192
|
function renderMinuteSpanInHour(ir, plan) {
|
|
191
193
|
const span = plan;
|
|
194
|
+
if (ir.pattern.minute === "*") {
|
|
195
|
+
return hourWord(span.hour) + "\u7684\u6BCF\u4E00\u5206\u949F";
|
|
196
|
+
}
|
|
192
197
|
return "\u5728" + hourWord(span.hour) + "\u81F3" + span.hour + "\u70B9" + span.span[1] + "\u5206\u4E4B\u95F4\uFF0C\u6BCF\u5206\u949F";
|
|
193
198
|
}
|
|
194
199
|
function renderMinutesAcrossHours(ir, plan) {
|
|
@@ -199,12 +204,17 @@ function renderMinutesAcrossHours(ir, plan) {
|
|
|
199
204
|
return hourList(ir) + "\uFF0C\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
|
|
200
205
|
}
|
|
201
206
|
function renderMinuteSpanAcrossHourStep(ir, plan) {
|
|
202
|
-
const
|
|
207
|
+
const hourStep = stepSegment(ir, "hour");
|
|
203
208
|
const { form } = plan;
|
|
204
|
-
|
|
205
|
-
|
|
209
|
+
const minuteTail = form === "wildcard" ? "\u6BCF\u5206\u949F" : "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
|
|
210
|
+
if (hourStep.startToken !== "*") {
|
|
211
|
+
return form === "wildcard" ? "\u5728" + hourList(ir) + "\uFF0C" + minuteTail : hourList(ir) + "\uFF0C" + minuteTail;
|
|
206
212
|
}
|
|
207
|
-
|
|
213
|
+
if (hourStep.interval === 2 && form === "wildcard") {
|
|
214
|
+
return "\u5728\u5076\u6570\u5C0F\u65F6\uFF0C" + minuteTail;
|
|
215
|
+
}
|
|
216
|
+
const cad = cadence(hourStep.interval, UNITS.hour);
|
|
217
|
+
return form === "wildcard" ? cad + "\u5185\uFF0C" + minuteTail : cad + "\uFF0C" + minuteTail;
|
|
208
218
|
}
|
|
209
219
|
function renderClockTimes(ir, plan, opts) {
|
|
210
220
|
const { times } = plan;
|
|
@@ -239,9 +249,6 @@ function renderHourStep(ir) {
|
|
|
239
249
|
if (segment.fires.length <= 2) {
|
|
240
250
|
return joinAnd(segment.fires.map(hourWord));
|
|
241
251
|
}
|
|
242
|
-
if (24 % segment.interval !== 0) {
|
|
243
|
-
return "\u4ECE" + hourWord(segment.fires[0]) + "\u8D77\uFF0C" + cadence(segment.interval, UNITS.hour);
|
|
244
|
-
}
|
|
245
252
|
return cadence(segment.interval, UNITS.hour);
|
|
246
253
|
}
|
|
247
254
|
function renderRangeOfMinutes(ir) {
|
|
@@ -293,9 +300,19 @@ function isHourCadence(ir) {
|
|
|
293
300
|
function composeSecondsOnHour(ir, plan, opts) {
|
|
294
301
|
const sec = secondClause(ir);
|
|
295
302
|
const { rest } = plan;
|
|
303
|
+
if ((rest.kind === "clockTimes" || rest.kind === "compactClockTimes") && ir.pattern.minute === "0") {
|
|
304
|
+
const clocks = hourFires(ir).map(function clock(hour) {
|
|
305
|
+
return hourWord(hour) + "0\u5206";
|
|
306
|
+
});
|
|
307
|
+
const tail = sec === "\u6BCF\u79D2" ? "\u7684\u6BCF\u4E00\u79D2" : "\u7684" + sec;
|
|
308
|
+
const core = joinAnd(clocks) + tail;
|
|
309
|
+
return isDaily(ir) ? "\u6BCF\u5929" + core : core;
|
|
310
|
+
}
|
|
296
311
|
const restText = render(ir, rest, opts);
|
|
297
|
-
if (
|
|
298
|
-
|
|
312
|
+
if (rest.kind === "clockTimes" || rest.kind === "compactClockTimes") {
|
|
313
|
+
if (isDaily(ir)) {
|
|
314
|
+
return "\u6BCF\u5929" + restText + sec;
|
|
315
|
+
}
|
|
299
316
|
}
|
|
300
317
|
if (rest.kind === "singleMinute") {
|
|
301
318
|
return restText + "\uFF0C" + sec;
|
|
@@ -319,6 +336,9 @@ function composeSecondsCadence(ir) {
|
|
|
319
336
|
function composeSecondsListed(ir) {
|
|
320
337
|
const sec = secondClause(ir);
|
|
321
338
|
const minutes = "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206");
|
|
339
|
+
if (ir.shapes.hour === "single" && sec === "\u6BCF\u79D2") {
|
|
340
|
+
return hourWord(hourFires(ir)[0]) + valueList(fieldSegments(ir, "minute"), "\u5206") + "\u7684\u6BCF\u4E00\u79D2";
|
|
341
|
+
}
|
|
322
342
|
if (ir.shapes.hour === "wildcard") {
|
|
323
343
|
return minutes + "\uFF0C" + sec;
|
|
324
344
|
}
|
|
@@ -328,10 +348,13 @@ function composeSecondsListed(ir) {
|
|
|
328
348
|
return hourFrame(ir) + minutes + "\uFF0C" + sec;
|
|
329
349
|
}
|
|
330
350
|
function renderComposeSeconds(ir, plan, opts) {
|
|
331
|
-
|
|
351
|
+
const { rest } = plan;
|
|
352
|
+
const composedClock = rest.kind === "clockTimes" || rest.kind === "compactClockTimes";
|
|
353
|
+
if (ir.pattern.minute === "0" || composedClock && ir.shapes.minute === "single") {
|
|
332
354
|
return composeSecondsOnHour(ir, plan, opts);
|
|
333
355
|
}
|
|
334
|
-
|
|
356
|
+
const minuteCadence = ir.pattern.minute === "*" || ir.shapes.minute === "step" && stepSegment(ir, "minute").startToken === "*";
|
|
357
|
+
if (minuteCadence) {
|
|
335
358
|
return composeSecondsCadence(ir);
|
|
336
359
|
}
|
|
337
360
|
return composeSecondsListed(ir);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cronli5",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Cron Like I'm Five: A Cron to English Utility",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"docs": "node --import tsx scripts/docs.mjs",
|
|
63
63
|
"fuzz": "node --import tsx scripts/fuzz-lang.mjs",
|
|
64
64
|
"lint": "eslint src test cli.js eslint.config.js scripts tooling/scripts",
|
|
65
|
+
"metamorphic": "node --import tsx tooling/scripts/metamorphic.mjs",
|
|
65
66
|
"lint:fix": "eslint src test cli.js eslint.config.js scripts tooling/scripts --fix",
|
|
66
67
|
"test": "mocha",
|
|
67
68
|
"types": "tsc -p tsconfig.types.json",
|
package/src/core/normalize.ts
CHANGED
|
@@ -4,10 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
import {fieldOrder, fieldSpecs} from './specs.js';
|
|
6
6
|
import type {CronLike, FieldSpec} from './specs.js';
|
|
7
|
-
import type {Pattern} from './ir.js';
|
|
7
|
+
import type {Field, Pattern} from './ir.js';
|
|
8
8
|
import {includes, toFieldNumber, unique} from './util.js';
|
|
9
9
|
import {isQuartzDate, isQuartzWeekday} from './validate.js';
|
|
10
10
|
|
|
11
|
+
// The fixed-cycle time fields: their step intervals are measured against a
|
|
12
|
+
// closed cycle (60 seconds, 60 minutes, 24 hours), so a step is a true
|
|
13
|
+
// "every N" cadence only when it tiles that cycle. The calendar fields
|
|
14
|
+
// (date/month/weekday) have variable cycles and keep their step form.
|
|
15
|
+
const timeFieldCycle: Partial<Record<Field, number>> = {
|
|
16
|
+
hour: 24,
|
|
17
|
+
minute: 60,
|
|
18
|
+
second: 60
|
|
19
|
+
};
|
|
20
|
+
|
|
11
21
|
// Quartz aliases: `?` reads "no specific value" (equivalent to `*`) in the
|
|
12
22
|
// date and weekday fields, and a bare `L` weekday means Saturday.
|
|
13
23
|
function applyQuartzAliases(cronPattern: CronLike): void {
|
|
@@ -40,7 +50,7 @@ function normalizeCronPattern(cronPattern: CronLike): Pattern {
|
|
|
40
50
|
return;
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
cronPattern[field] = normalizeField(value, fieldSpecs[field]);
|
|
53
|
+
cronPattern[field] = normalizeField(value, field, fieldSpecs[field]);
|
|
44
54
|
});
|
|
45
55
|
|
|
46
56
|
// Every field is now a canonical string.
|
|
@@ -48,17 +58,23 @@ function normalizeCronPattern(cronPattern: CronLike): Pattern {
|
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
// Canonicalize a single validated field value to a string.
|
|
51
|
-
function normalizeField(value: string, spec: FieldSpec): string {
|
|
61
|
+
function normalizeField(value: string, field: Field, spec: FieldSpec): string {
|
|
52
62
|
const stringValue = '' + value;
|
|
53
63
|
|
|
54
64
|
if (stringValue === '*') {
|
|
55
65
|
return stringValue;
|
|
56
66
|
}
|
|
57
67
|
|
|
68
|
+
const cycle = timeFieldCycle[field];
|
|
58
69
|
const segments = stringValue.split(',').map(function canonical(segment) {
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
return canonicalizeTokens(collapseFullSpanRange(
|
|
71
|
+
enumerateNonUniformStep(
|
|
72
|
+
collapseFullSpanStep(
|
|
73
|
+
collapseDegenerateRange(
|
|
74
|
+
collapseOnceStep(collapseUnitStep(segment, spec), spec), spec),
|
|
75
|
+
spec),
|
|
76
|
+
spec, cycle), spec), spec);
|
|
77
|
+
}).join(',').split(',');
|
|
62
78
|
|
|
63
79
|
// A full-cycle segment covers the whole field.
|
|
64
80
|
if (segments.indexOf('*') !== -1) {
|
|
@@ -70,6 +86,40 @@ function normalizeField(value: string, spec: FieldSpec): string {
|
|
|
70
86
|
}).join(',');
|
|
71
87
|
}
|
|
72
88
|
|
|
89
|
+
// Rewrite a segment's value tokens to their canonical numbers: a name
|
|
90
|
+
// (`MON`, `JAN`) becomes its index, and a weekday `7` (Sunday again, above
|
|
91
|
+
// `top`) folds to the field minimum (`0`). Applied to every token position —
|
|
92
|
+
// a single, both range bounds, and a step's start — so a normalized field
|
|
93
|
+
// never carries a surface name or the 7-for-Sunday alias. `*` and Quartz
|
|
94
|
+
// tokens (resolved earlier) are left untouched. Output is unchanged: a
|
|
95
|
+
// renderer maps the number back to its own name.
|
|
96
|
+
function canonicalizeTokens(segment: string, spec: FieldSpec): string {
|
|
97
|
+
// Only the named fields (month, weekday) carry name tokens or the
|
|
98
|
+
// weekday `7`-for-Sunday alias; every other field is already numeric.
|
|
99
|
+
if (!spec.numbers) {
|
|
100
|
+
return segment;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const parts = segment.split('/');
|
|
104
|
+
const start = parts[0].split('-').map(function fold(token) {
|
|
105
|
+
return canonicalizeToken(token, spec);
|
|
106
|
+
}).join('-');
|
|
107
|
+
|
|
108
|
+
return parts.length === 2 ? start + '/' + parts[1] : start;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A single token to its canonical number string (`*` passes through).
|
|
112
|
+
function canonicalizeToken(token: string, spec: FieldSpec): string {
|
|
113
|
+
if (token === '*') {
|
|
114
|
+
return token;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const number = toFieldNumber(token, spec.numbers);
|
|
118
|
+
|
|
119
|
+
// A value above `top` (weekday 7) is the field minimum again.
|
|
120
|
+
return '' + (number > (spec.top as number) ? spec.min : number);
|
|
121
|
+
}
|
|
122
|
+
|
|
73
123
|
// An interval-one step enumerates every value from its start, so it reads
|
|
74
124
|
// as the equivalent range: `1/1` is `1-59` and `5-30/1` is `5-30`. A start
|
|
75
125
|
// at the bottom of the cycle covers the whole field (`0/1` is `*`).
|
|
@@ -115,6 +165,94 @@ function collapseOnceStep(segment: string, spec: FieldSpec): string {
|
|
|
115
165
|
return start === '*' ? '' + spec.min : start;
|
|
116
166
|
}
|
|
117
167
|
|
|
168
|
+
// An unbounded step in a fixed-cycle time field is a true "every N" cadence
|
|
169
|
+
// only when it tiles the cycle: the interval divides it evenly and the start
|
|
170
|
+
// falls within the first interval (`*/15`, `5/6`). A step that fails either
|
|
171
|
+
// test fires at irregular points within the cycle, so it reads as the literal
|
|
172
|
+
// list of those fires (`*/7` is `0,7,14,…`), the same as if it were written
|
|
173
|
+
// out. Calendar fields (no `cycle`), bounded steps (`9-17/2`, a per-window
|
|
174
|
+
// stride), and non-step segments are left untouched.
|
|
175
|
+
function enumerateNonUniformStep(
|
|
176
|
+
segment: string,
|
|
177
|
+
spec: FieldSpec,
|
|
178
|
+
cycle: number | undefined
|
|
179
|
+
): string {
|
|
180
|
+
const parts = segment.split('/');
|
|
181
|
+
|
|
182
|
+
if (typeof cycle !== 'number' || parts.length !== 2 ||
|
|
183
|
+
includes(parts[0], '-')) {
|
|
184
|
+
return segment;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const interval = +parts[1];
|
|
188
|
+
const start = parts[0] === '*' ? spec.min : toFieldNumber(parts[0]);
|
|
189
|
+
|
|
190
|
+
if (cycle % interval === 0 && start < interval) {
|
|
191
|
+
return segment;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const fires = [];
|
|
195
|
+
|
|
196
|
+
for (let value = start; value <= (spec.top as number); value += interval) {
|
|
197
|
+
fires.push(value);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return fires.join(',');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A step whose range part covers the whole field (`0-59/2`, `0-23/2`) strides
|
|
204
|
+
// across the entire cycle, so the bound adds nothing — it reads as the
|
|
205
|
+
// unbounded `*/N` step (which the cycle test then renders as "every N" or its
|
|
206
|
+
// fires). Partial-range steps (`9-17/2`) keep their window.
|
|
207
|
+
function collapseFullSpanStep(segment: string, spec: FieldSpec): string {
|
|
208
|
+
const parts = segment.split('/');
|
|
209
|
+
|
|
210
|
+
if (parts.length !== 2 || !includes(parts[0], '-')) {
|
|
211
|
+
return segment;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return collapseFullSpanRange(parts[0], spec) === '*' ?
|
|
215
|
+
'*/' + parts[1] :
|
|
216
|
+
segment;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// A plain range whose enumerated values cover the whole field imposes no
|
|
220
|
+
// restriction, so it reads identically to `*` (`0-59` minute, `0-23` hour,
|
|
221
|
+
// `1-31` date, `1-12` month, and every seven-day weekday range — `0-6`,
|
|
222
|
+
// `1-7`, `0-7`, `SUN-SAT` — since cron's 7 is Sunday again, folding to the
|
|
223
|
+
// field minimum). Only bare ranges qualify: a step (`0-59/2`) keeps its
|
|
224
|
+
// cadence, so segments carrying a `/` are left untouched.
|
|
225
|
+
function collapseFullSpanRange(segment: string, spec: FieldSpec): string {
|
|
226
|
+
if (typeof spec.top !== 'number' || includes(segment, '/') ||
|
|
227
|
+
!includes(segment, '-')) {
|
|
228
|
+
return segment;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const bounds = segment.split('-');
|
|
232
|
+
const low = toFieldNumber(bounds[0], spec.numbers);
|
|
233
|
+
const high = toFieldNumber(bounds[1], spec.numbers);
|
|
234
|
+
|
|
235
|
+
if (low > high) {
|
|
236
|
+
return segment;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The full field is min..top; a value above top (weekday 7) folds to min.
|
|
240
|
+
const top = spec.top as number;
|
|
241
|
+
const fired: Record<number, boolean> = {};
|
|
242
|
+
|
|
243
|
+
for (let value = low; value <= high; value += 1) {
|
|
244
|
+
fired[value > top ? spec.min : value] = true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (let value = spec.min; value <= top; value += 1) {
|
|
248
|
+
if (!fired[value]) {
|
|
249
|
+
return segment;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return '*';
|
|
254
|
+
}
|
|
255
|
+
|
|
118
256
|
// A degenerate range (`9-9`) fires once, so it reads as its single value.
|
|
119
257
|
// A stepped degenerate range (`9-9/5`) likewise fires only at its start.
|
|
120
258
|
function collapseDegenerateRange(segment: string, spec: FieldSpec): string {
|
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 {weekdayNumbers} from '../../core/specs.js';
|
|
6
|
+
import {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
|
|
@@ -61,11 +63,6 @@ const weekdayNames = [
|
|
|
61
63
|
'freitags', 'samstags'
|
|
62
64
|
];
|
|
63
65
|
|
|
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
66
|
function fieldSegments(ir: IR, field: Field): Segment[] {
|
|
70
67
|
return ir.analyses.segments[field] as Segment[];
|
|
71
68
|
}
|
|
@@ -94,14 +91,9 @@ function joinList(items: string[]): string {
|
|
|
94
91
|
return items.slice(0, -1).join(', ') + ' und ' + items[items.length - 1];
|
|
95
92
|
}
|
|
96
93
|
|
|
97
|
-
// The adverbial name for a weekday
|
|
94
|
+
// The adverbial name for a canonical weekday number (0 = Sunday).
|
|
98
95
|
function weekdayName(token: NameToken): string {
|
|
99
|
-
|
|
100
|
-
return weekdayNames[0];
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return weekdayNames[token as number] ||
|
|
104
|
-
weekdayNames[weekdayTokens[token as string]];
|
|
96
|
+
return weekdayNames[+token];
|
|
105
97
|
}
|
|
106
98
|
|
|
107
99
|
// "montags bis freitags".
|
|
@@ -150,12 +142,10 @@ function everyNthHour(segment: StepSegment): string {
|
|
|
150
142
|
return start === 0 ? base : base + ' ab ' + start + ' Uhr';
|
|
151
143
|
}
|
|
152
144
|
|
|
145
|
+
// The Quartz weekday stem (`5L`, `MON#2`) is not number-canonicalized in the
|
|
146
|
+
// core, so it may still be a name token; resolve it via the core's index.
|
|
153
147
|
function weekdayNoun(token: string): string {
|
|
154
|
-
|
|
155
|
-
return weekdayNouns[0];
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return weekdayNouns[token in weekdayTokens ? weekdayTokens[token] : +token];
|
|
148
|
+
return weekdayNouns[toFieldNumber(token, weekdayNumbers)];
|
|
159
149
|
}
|
|
160
150
|
|
|
161
151
|
// The Quartz weekday phrase: "am letzten Freitag des Monats", "am zweiten
|
|
@@ -193,18 +183,12 @@ function quartzDate(field: string): string | null {
|
|
|
193
183
|
return null;
|
|
194
184
|
}
|
|
195
185
|
|
|
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
186
|
type Months = GermanStyle['months'];
|
|
204
187
|
|
|
188
|
+
// The month names are dialect-scoped (resolved from `opts.style.months`);
|
|
189
|
+
// the canonical month number indexes them.
|
|
205
190
|
function monthName(token: NameToken, months: Months): string {
|
|
206
|
-
return
|
|
207
|
-
months[monthTokens[token as string]]) as string;
|
|
191
|
+
return months[+token] as string;
|
|
208
192
|
}
|
|
209
193
|
|
|
210
194
|
// "von Juni bis August".
|
|
@@ -462,7 +446,9 @@ function duringHours(ir: IR, times: HourTimesPlan, sep: string): string {
|
|
|
462
446
|
return joinList(windows);
|
|
463
447
|
}
|
|
464
448
|
|
|
465
|
-
|
|
449
|
+
// A discrete set of hours is a list, not a range, so it takes no "von"
|
|
450
|
+
// (which would read as "von X bis Y"); it mirrors the minute list form.
|
|
451
|
+
return 'in den Stunden ' + joinList(times.fires.map(String)) + ' Uhr';
|
|
466
452
|
}
|
|
467
453
|
|
|
468
454
|
// --- Renderers. ---
|
|
@@ -509,12 +495,33 @@ function renderSecondsWithinMinute(
|
|
|
509
495
|
' jeder Stunde';
|
|
510
496
|
}
|
|
511
497
|
|
|
512
|
-
//
|
|
498
|
+
// The whole-hour noun in the genitive: "der Mitternachtsstunde" (0), "der
|
|
499
|
+
// Mittagsstunde" (12), or "der <H>-Uhr-Stunde" for any other hour.
|
|
500
|
+
function wholeHour(hour: number): string {
|
|
501
|
+
if (hour === 0) {
|
|
502
|
+
return 'der Mitternachtsstunde';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (hour === 12) {
|
|
506
|
+
return 'der Mittagsstunde';
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return 'der ' + hour + '-Uhr-Stunde';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// A minute span inside one hour: "jede Minute von 9:00 bis 9:30 Uhr". A
|
|
513
|
+
// wildcard minute is the whole hour, so it reads as that hour itself ("jede
|
|
514
|
+
// Minute der 9-Uhr-Stunde") rather than a synthesized "von 9:00 bis 9:59"
|
|
515
|
+
// range the source never stated; a plain range is a real window and keeps it.
|
|
513
516
|
function renderMinuteSpanInHour(
|
|
514
517
|
ir: IR,
|
|
515
518
|
plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
|
|
516
519
|
opts: Opts
|
|
517
520
|
): string {
|
|
521
|
+
if (ir.pattern.minute === '*') {
|
|
522
|
+
return 'jede Minute ' + wholeHour(plan.hour);
|
|
523
|
+
}
|
|
524
|
+
|
|
518
525
|
const sep = opts.style.sep;
|
|
519
526
|
|
|
520
527
|
return 'jede Minute von ' + spanTime(plan.hour, plan.span[0], sep) +
|
|
@@ -528,9 +535,47 @@ function renderComposeSeconds(
|
|
|
528
535
|
plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
|
|
529
536
|
opts: Opts
|
|
530
537
|
): string {
|
|
538
|
+
// A sub-minute second with the minute pinned to 0 and a specific hour: the
|
|
539
|
+
// clock-time rest would read "um 9 Uhr", which hides the pinned :00 (and so
|
|
540
|
+
// the one-minute confinement — 60 fires in :00, not 3,600 across the hour).
|
|
541
|
+
// Bind the seconds into the explicit clock minute in the genitive ("der
|
|
542
|
+
// Minute 9:00"); the recurring "täglich"/day frame is added in `describe`.
|
|
543
|
+
if (composeMinuteZero(ir, plan)) {
|
|
544
|
+
return secondsLead(ir) + ' ' +
|
|
545
|
+
clockMinuteGenitive(plan.rest.times, opts.style.sep);
|
|
546
|
+
}
|
|
547
|
+
|
|
531
548
|
return secondsLead(ir) + ', ' + render(ir, plan.rest, opts);
|
|
532
549
|
}
|
|
533
550
|
|
|
551
|
+
// True when a compose-seconds plan is a sub-minute second over a minute-0
|
|
552
|
+
// clock-time rest — the case that reads as the bare hour and so must surface
|
|
553
|
+
// the pinned clock minute.
|
|
554
|
+
function composeMinuteZero(
|
|
555
|
+
ir: IR,
|
|
556
|
+
plan: Extract<PlanNode, {kind: 'composeSeconds'}>
|
|
557
|
+
): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
|
|
558
|
+
{rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
|
|
559
|
+
return plan.rest.kind === 'clockTimes' &&
|
|
560
|
+
plan.rest.times.every((time) => +time.minute === 0);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// The pinned clock minute in the genitive: "der Minute 9:00" for one hour,
|
|
564
|
+
// "der Minuten 9:00, 10:00 und 17:00" for several — the explicit ":00" so the
|
|
565
|
+
// minute-0 confinement stays visible.
|
|
566
|
+
function clockMinuteGenitive(
|
|
567
|
+
times: {hour: number; minute: number}[],
|
|
568
|
+
sep: string
|
|
569
|
+
): string {
|
|
570
|
+
const clocks = times.map(function clock(time): string {
|
|
571
|
+
return time.hour + sep + pad(time.minute);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
return clocks.length === 1 ?
|
|
575
|
+
'der Minute ' + clocks[0] :
|
|
576
|
+
'der Minuten ' + joinList(clocks);
|
|
577
|
+
}
|
|
578
|
+
|
|
534
579
|
// A minute clause across discrete hours: "in den Minuten 0 bis 30, um 9 und
|
|
535
580
|
// 17 Uhr".
|
|
536
581
|
function renderMinutesAcrossHours(
|
|
@@ -739,15 +784,31 @@ function qualifier(ir: IR, months: Months): string {
|
|
|
739
784
|
}
|
|
740
785
|
|
|
741
786
|
// Plan kinds whose clause is a clock time: the qualifier leads them ("montags
|
|
742
|
-
// um 9 Uhr"); a frequency clause trails it ("jede Minute montags").
|
|
787
|
+
// um 9 Uhr"); a frequency clause trails it ("jede Minute montags"). The
|
|
788
|
+
// minute-0 compose-seconds clause is anchored on a clock minute too, so the
|
|
789
|
+
// qualifier leads it ("montags jede Sekunde der Minute 9:00").
|
|
743
790
|
const LEADING_PLANS = new Set(['clockTimes']);
|
|
744
791
|
|
|
792
|
+
// True when the leading qualifier should precede the clause: a clock-time
|
|
793
|
+
// plan, or the minute-0 compose-seconds clause that surfaces a clock minute.
|
|
794
|
+
function leadsQualifier(ir: IR): boolean {
|
|
795
|
+
return LEADING_PLANS.has(ir.plan.kind) || isComposeMinuteZero(ir);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Whether the planned clause is the minute-0 compose-seconds confinement
|
|
799
|
+
// (a sub-minute second over a minute-0 clock-time rest).
|
|
800
|
+
function isComposeMinuteZero(ir: IR): boolean {
|
|
801
|
+
return ir.plan.kind === 'composeSeconds' &&
|
|
802
|
+
composeMinuteZero(ir, ir.plan);
|
|
803
|
+
}
|
|
804
|
+
|
|
745
805
|
// True when the clause is a bare daily clock-time list and so needs the
|
|
746
|
-
// "täglich" frame to read as recurring, not a one-off: clockTimes always,
|
|
747
|
-
//
|
|
748
|
-
//
|
|
806
|
+
// "täglich" frame to read as recurring, not a one-off: clockTimes always, the
|
|
807
|
+
// minute-0 compose-seconds clause (a recurring clock minute), and an uneven
|
|
808
|
+
// hour step (rendered as its fire list "um 0, 5, … Uhr", not the cadence "alle
|
|
809
|
+
// N Stunden"). A frequency clause already implies recurrence.
|
|
749
810
|
function needsDailyFrame(ir: IR): boolean {
|
|
750
|
-
if (ir.plan.kind === 'clockTimes') {
|
|
811
|
+
if (ir.plan.kind === 'clockTimes' || isComposeMinuteZero(ir)) {
|
|
751
812
|
return true;
|
|
752
813
|
}
|
|
753
814
|
|
|
@@ -802,7 +863,7 @@ function describe(ir: IR, opts: Opts): string {
|
|
|
802
863
|
let base = core;
|
|
803
864
|
|
|
804
865
|
if (qual) {
|
|
805
|
-
base =
|
|
866
|
+
base = leadsQualifier(ir) ?
|
|
806
867
|
qual + ' ' + core :
|
|
807
868
|
core + ' ' + qual;
|
|
808
869
|
}
|
|
@@ -818,7 +879,10 @@ const de: Language<GermanStyle> = {
|
|
|
818
879
|
fallback: 'ein unlesbares Cron-Muster',
|
|
819
880
|
options: normalizeOptions,
|
|
820
881
|
reboot: 'beim Systemstart',
|
|
821
|
-
|
|
882
|
+
// A description ending in a German ordinal already carries its period
|
|
883
|
+
// ("…am 8."), so closing the sentence must not double it.
|
|
884
|
+
sentence: (description) =>
|
|
885
|
+
'Läuft ' + description + (description.endsWith('.') ? '' : '.')
|
|
822
886
|
};
|
|
823
887
|
|
|
824
888
|
export default de;
|