cronli5 0.1.2 → 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 +64 -0
- package/cli.js +9 -0
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +100 -28
- package/dist/cronli5.js +100 -28
- package/dist/lang/de.cjs +55 -34
- package/dist/lang/de.js +55 -34
- package/dist/lang/en.cjs +41 -21
- package/dist/lang/en.js +41 -21
- package/dist/lang/es.cjs +79 -29
- package/dist/lang/es.js +79 -29
- package/dist/lang/fi.cjs +42 -29
- package/dist/lang/fi.js +42 -29
- package/dist/lang/zh.cjs +34 -9
- package/dist/lang/zh.js +34 -9
- package/package.json +2 -1
- package/src/core/normalize.ts +94 -4
- package/src/lang/de/index.ts +92 -33
- package/src/lang/en/index.ts +100 -30
- package/src/lang/es/index.ts +134 -25
- package/src/lang/fi/index.ts +54 -20
- package/src/lang/zh/index.ts +83 -12
package/dist/lang/zh.js
CHANGED
|
@@ -175,7 +175,8 @@ function renderMinuteFrequency(ir, plan) {
|
|
|
175
175
|
const base = minuteStep.startToken === "*" ? cadence(minuteStep.interval, UNITS.minute) : renderMinutePast(ir);
|
|
176
176
|
const { hours } = plan;
|
|
177
177
|
if (hours.kind === "step") {
|
|
178
|
-
|
|
178
|
+
const hourStep = stepSegment(ir, "hour");
|
|
179
|
+
return hourStep.startToken === "*" ? cadence(hourStep.interval, UNITS.hour) + base : "\u5728" + hourList(ir) + "\uFF0C" + base;
|
|
179
180
|
}
|
|
180
181
|
if (hours.kind === "single" || hours.kind === "window" && hours.from === hours.to) {
|
|
181
182
|
return "\u5728" + hourWord(hours.from) + "\u81F3" + hours.from + "\u70B9" + hours.last + "\u5206\u4E4B\u95F4\uFF0C" + base;
|
|
@@ -190,6 +191,9 @@ function renderMinuteFrequency(ir, plan) {
|
|
|
190
191
|
}
|
|
191
192
|
function renderMinuteSpanInHour(ir, plan) {
|
|
192
193
|
const span = plan;
|
|
194
|
+
if (ir.pattern.minute === "*") {
|
|
195
|
+
return hourWord(span.hour) + "\u7684\u6BCF\u4E00\u5206\u949F";
|
|
196
|
+
}
|
|
193
197
|
return "\u5728" + hourWord(span.hour) + "\u81F3" + span.hour + "\u70B9" + span.span[1] + "\u5206\u4E4B\u95F4\uFF0C\u6BCF\u5206\u949F";
|
|
194
198
|
}
|
|
195
199
|
function renderMinutesAcrossHours(ir, plan) {
|
|
@@ -200,12 +204,17 @@ function renderMinutesAcrossHours(ir, plan) {
|
|
|
200
204
|
return hourList(ir) + "\uFF0C\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
|
|
201
205
|
}
|
|
202
206
|
function renderMinuteSpanAcrossHourStep(ir, plan) {
|
|
203
|
-
const
|
|
207
|
+
const hourStep = stepSegment(ir, "hour");
|
|
204
208
|
const { form } = plan;
|
|
205
|
-
|
|
206
|
-
|
|
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;
|
|
207
212
|
}
|
|
208
|
-
|
|
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;
|
|
209
218
|
}
|
|
210
219
|
function renderClockTimes(ir, plan, opts) {
|
|
211
220
|
const { times } = plan;
|
|
@@ -291,9 +300,19 @@ function isHourCadence(ir) {
|
|
|
291
300
|
function composeSecondsOnHour(ir, plan, opts) {
|
|
292
301
|
const sec = secondClause(ir);
|
|
293
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
|
+
}
|
|
294
311
|
const restText = render(ir, rest, opts);
|
|
295
|
-
if (
|
|
296
|
-
|
|
312
|
+
if (rest.kind === "clockTimes" || rest.kind === "compactClockTimes") {
|
|
313
|
+
if (isDaily(ir)) {
|
|
314
|
+
return "\u6BCF\u5929" + restText + sec;
|
|
315
|
+
}
|
|
297
316
|
}
|
|
298
317
|
if (rest.kind === "singleMinute") {
|
|
299
318
|
return restText + "\uFF0C" + sec;
|
|
@@ -317,6 +336,9 @@ function composeSecondsCadence(ir) {
|
|
|
317
336
|
function composeSecondsListed(ir) {
|
|
318
337
|
const sec = secondClause(ir);
|
|
319
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
|
+
}
|
|
320
342
|
if (ir.shapes.hour === "wildcard") {
|
|
321
343
|
return minutes + "\uFF0C" + sec;
|
|
322
344
|
}
|
|
@@ -326,10 +348,13 @@ function composeSecondsListed(ir) {
|
|
|
326
348
|
return hourFrame(ir) + minutes + "\uFF0C" + sec;
|
|
327
349
|
}
|
|
328
350
|
function renderComposeSeconds(ir, plan, opts) {
|
|
329
|
-
|
|
351
|
+
const { rest } = plan;
|
|
352
|
+
const composedClock = rest.kind === "clockTimes" || rest.kind === "compactClockTimes";
|
|
353
|
+
if (ir.pattern.minute === "0" || composedClock && ir.shapes.minute === "single") {
|
|
330
354
|
return composeSecondsOnHour(ir, plan, opts);
|
|
331
355
|
}
|
|
332
|
-
|
|
356
|
+
const minuteCadence = ir.pattern.minute === "*" || ir.shapes.minute === "step" && stepSegment(ir, "minute").startToken === "*";
|
|
357
|
+
if (minuteCadence) {
|
|
333
358
|
return composeSecondsCadence(ir);
|
|
334
359
|
}
|
|
335
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
|
@@ -67,10 +67,13 @@ function normalizeField(value: string, field: Field, spec: FieldSpec): string {
|
|
|
67
67
|
|
|
68
68
|
const cycle = timeFieldCycle[field];
|
|
69
69
|
const segments = stringValue.split(',').map(function canonical(segment) {
|
|
70
|
-
return
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
return canonicalizeTokens(collapseFullSpanRange(
|
|
71
|
+
enumerateNonUniformStep(
|
|
72
|
+
collapseFullSpanStep(
|
|
73
|
+
collapseDegenerateRange(
|
|
74
|
+
collapseOnceStep(collapseUnitStep(segment, spec), spec), spec),
|
|
75
|
+
spec),
|
|
76
|
+
spec, cycle), spec), spec);
|
|
74
77
|
}).join(',').split(',');
|
|
75
78
|
|
|
76
79
|
// A full-cycle segment covers the whole field.
|
|
@@ -83,6 +86,40 @@ function normalizeField(value: string, field: Field, spec: FieldSpec): string {
|
|
|
83
86
|
}).join(',');
|
|
84
87
|
}
|
|
85
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
|
+
|
|
86
123
|
// An interval-one step enumerates every value from its start, so it reads
|
|
87
124
|
// as the equivalent range: `1/1` is `1-59` and `5-30/1` is `5-30`. A start
|
|
88
125
|
// at the bottom of the cycle covers the whole field (`0/1` is `*`).
|
|
@@ -163,6 +200,59 @@ function enumerateNonUniformStep(
|
|
|
163
200
|
return fires.join(',');
|
|
164
201
|
}
|
|
165
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
|
+
|
|
166
256
|
// A degenerate range (`9-9`) fires once, so it reads as its single value.
|
|
167
257
|
// A stepped degenerate range (`9-9/5`) likewise fires only at its start.
|
|
168
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".
|
|
@@ -511,12 +495,33 @@ function renderSecondsWithinMinute(
|
|
|
511
495
|
' jeder Stunde';
|
|
512
496
|
}
|
|
513
497
|
|
|
514
|
-
//
|
|
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.
|
|
515
516
|
function renderMinuteSpanInHour(
|
|
516
517
|
ir: IR,
|
|
517
518
|
plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
|
|
518
519
|
opts: Opts
|
|
519
520
|
): string {
|
|
521
|
+
if (ir.pattern.minute === '*') {
|
|
522
|
+
return 'jede Minute ' + wholeHour(plan.hour);
|
|
523
|
+
}
|
|
524
|
+
|
|
520
525
|
const sep = opts.style.sep;
|
|
521
526
|
|
|
522
527
|
return 'jede Minute von ' + spanTime(plan.hour, plan.span[0], sep) +
|
|
@@ -530,9 +535,47 @@ function renderComposeSeconds(
|
|
|
530
535
|
plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
|
|
531
536
|
opts: Opts
|
|
532
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
|
+
|
|
533
548
|
return secondsLead(ir) + ', ' + render(ir, plan.rest, opts);
|
|
534
549
|
}
|
|
535
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
|
+
|
|
536
579
|
// A minute clause across discrete hours: "in den Minuten 0 bis 30, um 9 und
|
|
537
580
|
// 17 Uhr".
|
|
538
581
|
function renderMinutesAcrossHours(
|
|
@@ -741,15 +784,31 @@ function qualifier(ir: IR, months: Months): string {
|
|
|
741
784
|
}
|
|
742
785
|
|
|
743
786
|
// 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").
|
|
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").
|
|
745
790
|
const LEADING_PLANS = new Set(['clockTimes']);
|
|
746
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
|
+
|
|
747
805
|
// 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
|
-
//
|
|
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.
|
|
751
810
|
function needsDailyFrame(ir: IR): boolean {
|
|
752
|
-
if (ir.plan.kind === 'clockTimes') {
|
|
811
|
+
if (ir.plan.kind === 'clockTimes' || isComposeMinuteZero(ir)) {
|
|
753
812
|
return true;
|
|
754
813
|
}
|
|
755
814
|
|
|
@@ -804,7 +863,7 @@ function describe(ir: IR, opts: Opts): string {
|
|
|
804
863
|
let base = core;
|
|
805
864
|
|
|
806
865
|
if (qual) {
|
|
807
|
-
base =
|
|
866
|
+
base = leadsQualifier(ir) ?
|
|
808
867
|
qual + ' ' + core :
|
|
809
868
|
core + ' ' + qual;
|
|
810
869
|
}
|
package/src/lang/en/index.ts
CHANGED
|
@@ -21,12 +21,15 @@ type StepSegment = Extract<Segment, {kind: 'step'}>;
|
|
|
21
21
|
|
|
22
22
|
// A clock-time entry assembled for rendering. Hour/minute/second arrive as
|
|
23
23
|
// numbers or as raw field tokens (a range bound or single value is a
|
|
24
|
-
// string); `plain` suppresses the noon/midnight words.
|
|
24
|
+
// string); `plain` suppresses the noon/midnight words. `explicit` forces the
|
|
25
|
+
// minute to show even when zero ("9:00 a.m.", not "9 a.m.") and suppresses
|
|
26
|
+
// the noon/midnight words, so a pinned minute-0 stays visible.
|
|
25
27
|
interface TimeEntry {
|
|
26
28
|
hour: number | string;
|
|
27
29
|
minute: number | string;
|
|
28
30
|
second?: number | string | null;
|
|
29
31
|
plain?: boolean;
|
|
32
|
+
explicit?: boolean;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
// English number names for the integers zero through ten.
|
|
@@ -80,22 +83,6 @@ const weekdayNames: [string, string][] = [
|
|
|
80
83
|
['Saturday', 'Sat']
|
|
81
84
|
];
|
|
82
85
|
|
|
83
|
-
// Month names by abbreviation.
|
|
84
|
-
const monthAbbreviations: Record<string, [string, string] | null> = {
|
|
85
|
-
JAN: monthNames[1],
|
|
86
|
-
FEB: monthNames[2],
|
|
87
|
-
MAR: monthNames[3],
|
|
88
|
-
APR: monthNames[4],
|
|
89
|
-
MAY: monthNames[5],
|
|
90
|
-
JUN: monthNames[6],
|
|
91
|
-
JUL: monthNames[7],
|
|
92
|
-
AUG: monthNames[8],
|
|
93
|
-
SEP: monthNames[9],
|
|
94
|
-
OCT: monthNames[10],
|
|
95
|
-
NOV: monthNames[11],
|
|
96
|
-
DEC: monthNames[12]
|
|
97
|
-
};
|
|
98
|
-
|
|
99
86
|
// Weekday name by abbreviation.
|
|
100
87
|
const weekdayAbbreviations: Record<string, [string, string]> = {
|
|
101
88
|
SUN: weekdayNames[0],
|
|
@@ -192,9 +179,78 @@ function renderSecondsWithinMinute(ir: IR, plan: PlanOf<'secondsWithinMinute'>,
|
|
|
192
179
|
// pattern follows.
|
|
193
180
|
function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
194
181
|
opts: NormalizedOptions): string {
|
|
182
|
+
// A wildcard or stepped second under a minute pinned to a single value
|
|
183
|
+
// across one or more specific hours. The clock-time rest collapses the
|
|
184
|
+
// pinned minute into the hour, and on the clock a pinned minute-0 reads as
|
|
185
|
+
// the whole hour ("9 a.m." spoken == "9:00 a.m."), losing the one-minute
|
|
186
|
+
// confinement. (A second list/range/single leads with a "past the minute"
|
|
187
|
+
// clause that an "of"/duration frame cannot follow, so it stays generic.)
|
|
188
|
+
if (plan.rest.kind === 'clockTimes' &&
|
|
189
|
+
(ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
|
|
190
|
+
const minute = plan.rest.times[0].minute;
|
|
191
|
+
|
|
192
|
+
// Minute 0 is the one-minute window at the top of each named hour: a
|
|
193
|
+
// duration frame ("for one minute at 9 a.m.") states the confinement
|
|
194
|
+
// outright, with the hour as its word so it cannot be heard as the hour
|
|
195
|
+
// itself. A non-zero pinned minute is an unambiguous clock time, so the
|
|
196
|
+
// compact "of 9:05 a.m." form reads it as the minute, never the hour.
|
|
197
|
+
if (+minute === 0) {
|
|
198
|
+
return secondsLeadClause(ir, opts) + ' for one minute at ' +
|
|
199
|
+
durationHours(ir, plan.rest, opts);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return secondsLeadClause(ir, opts) + ' of ' +
|
|
203
|
+
clockTimesOf(ir, plan.rest, opts);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A wildcard second under a */2 minute step with a wildcard hour binds
|
|
207
|
+
// idiomatically as "every second of every other minute": "every other" is
|
|
208
|
+
// the natural English for an interval of 2, and "of" joins the two without
|
|
209
|
+
// the ambiguity of a comma, which reads as two independent cadences.
|
|
210
|
+
// Scoped to */2 only; other step sizes keep the comma form.
|
|
211
|
+
if (ir.shapes.second === 'wildcard' &&
|
|
212
|
+
plan.rest.kind === 'minuteFrequency' &&
|
|
213
|
+
plan.rest.hours.kind === 'none' &&
|
|
214
|
+
ir.pattern.minute === '*/2') {
|
|
215
|
+
return 'every second of every other minute' +
|
|
216
|
+
trailingQualifier(ir, opts);
|
|
217
|
+
}
|
|
218
|
+
|
|
195
219
|
return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
|
|
196
220
|
}
|
|
197
221
|
|
|
222
|
+
// The bare-hour words for a minute-0 duration confinement, joined and followed
|
|
223
|
+
// by the trailing day qualifier: "9 a.m. and 11 a.m., every day", "midnight,
|
|
224
|
+
// 2 a.m., …, every day". The hour reads as its word (noon/midnight included),
|
|
225
|
+
// never "H:00", since the "for one minute" frame already carries the minute.
|
|
226
|
+
function durationHours(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
227
|
+
opts: NormalizedOptions): string {
|
|
228
|
+
const hours = plan.times.map(function clock(time) {
|
|
229
|
+
return getTime({hour: time.hour, minute: 0}, opts);
|
|
230
|
+
});
|
|
231
|
+
const trail = dayQualifier(ir, leadingWords, opts);
|
|
232
|
+
|
|
233
|
+
return joinList(hours, opts) + (trail && ', ' + trail);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The clock times for a non-zero pinned-minute compose-seconds rest, joined
|
|
237
|
+
// and followed by the trailing day qualifier: "9:05 a.m. and 11:05 a.m.,
|
|
238
|
+
// every day". The non-zero minute reads as a clock time, never the hour.
|
|
239
|
+
function clockTimesOf(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
240
|
+
opts: NormalizedOptions): string {
|
|
241
|
+
const times = plan.times.map(function clock(time) {
|
|
242
|
+
return getTime({
|
|
243
|
+
hour: time.hour,
|
|
244
|
+
minute: time.minute,
|
|
245
|
+
second: time.second,
|
|
246
|
+
explicit: true
|
|
247
|
+
}, opts);
|
|
248
|
+
});
|
|
249
|
+
const trail = dayQualifier(ir, leadingWords, opts);
|
|
250
|
+
|
|
251
|
+
return joinList(times, opts) + (trail && ', ' + trail);
|
|
252
|
+
}
|
|
253
|
+
|
|
198
254
|
// The leading clause describing a second field relative to the minute,
|
|
199
255
|
// e.g. "at 5 and 10 seconds past the minute" or "every second from zero
|
|
200
256
|
// through 30 past the minute".
|
|
@@ -289,9 +345,18 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
|
|
|
289
345
|
}
|
|
290
346
|
|
|
291
347
|
// A minute wildcard or plain range under a single specific hour fires
|
|
292
|
-
// every minute within a window inside that hour.
|
|
348
|
+
// every minute within a window inside that hour. A wildcard minute is the
|
|
349
|
+
// whole hour, so it reads as that hour itself ("every minute of the 9 a.m.
|
|
350
|
+
// hour") rather than a synthesized "from H:00 through H:59" range the source
|
|
351
|
+
// never stated; a plain range is a real window and keeps "from … through …".
|
|
293
352
|
function renderMinuteSpanInHour(ir: IR, plan: PlanOf<'minuteSpanInHour'>,
|
|
294
353
|
opts: NormalizedOptions): string {
|
|
354
|
+
if (ir.pattern.minute === '*') {
|
|
355
|
+
return 'every minute of the ' +
|
|
356
|
+
getTime({hour: plan.hour, minute: 0}, opts) + ' hour' +
|
|
357
|
+
trailingQualifier(ir, opts);
|
|
358
|
+
}
|
|
359
|
+
|
|
295
360
|
return 'every minute from ' +
|
|
296
361
|
getTime({hour: plan.hour, minute: plan.span[0]}, opts) +
|
|
297
362
|
through(opts) + getTime({hour: plan.hour, minute: plan.span[1]}, opts) +
|
|
@@ -1169,7 +1234,7 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
|
|
|
1169
1234
|
// "3.45pm" / "9am" / "midday" for UK (Guardian), or "15:45" / "15.45" in
|
|
1170
1235
|
// 24-hour mode.
|
|
1171
1236
|
function getTime(time: TimeEntry, opts: NormalizedOptions): string {
|
|
1172
|
-
const {hour, minute, plain} = time;
|
|
1237
|
+
const {hour, minute, plain, explicit} = time;
|
|
1173
1238
|
// Seconds are only shown when a specific non-zero value is supplied.
|
|
1174
1239
|
const second = typeof time.second === 'number' && time.second > 0 ?
|
|
1175
1240
|
time.second :
|
|
@@ -1179,12 +1244,13 @@ function getTime(time: TimeEntry, opts: NormalizedOptions): string {
|
|
|
1179
1244
|
// Hour/minute arrive as numbers or raw field tokens (a range bound or
|
|
1180
1245
|
// single value is a string); `clockDigits` types them as numbers but
|
|
1181
1246
|
// `pad` stringifies either form to the same digits. Cast to keep the
|
|
1182
|
-
// value byte-identical rather than coercing it.
|
|
1247
|
+
// value byte-identical rather than coercing it. The 24-hour form always
|
|
1248
|
+
// shows the minute, so it is already explicit.
|
|
1183
1249
|
return clockDigits({hour: hour as number, minute: minute as number,
|
|
1184
1250
|
second}, {pad: true, sep: opts.style.sep});
|
|
1185
1251
|
}
|
|
1186
1252
|
|
|
1187
|
-
return twelveHourTime({hour, minute, second, plain}, opts);
|
|
1253
|
+
return twelveHourTime({hour, minute, second, plain, explicit}, opts);
|
|
1188
1254
|
}
|
|
1189
1255
|
|
|
1190
1256
|
// The 12-hour form of a clock time: "9:30 a.m.", "9 a.m." on the hour, or
|
|
@@ -1193,13 +1259,13 @@ function getTime(time: TimeEntry, opts: NormalizedOptions): string {
|
|
|
1193
1259
|
// stays in one number style.
|
|
1194
1260
|
function twelveHourTime(
|
|
1195
1261
|
time: {hour: number | string; minute: number | string; second: number;
|
|
1196
|
-
plain?: boolean},
|
|
1262
|
+
plain?: boolean; explicit?: boolean},
|
|
1197
1263
|
opts: NormalizedOptions
|
|
1198
1264
|
): string {
|
|
1199
|
-
const {hour, minute, second, plain} = time;
|
|
1265
|
+
const {hour, minute, second, plain, explicit} = time;
|
|
1200
1266
|
const style = opts.style;
|
|
1201
1267
|
|
|
1202
|
-
if (!plain && +minute === 0 && !second) {
|
|
1268
|
+
if (!plain && !explicit && +minute === 0 && !second) {
|
|
1203
1269
|
if (+hour === 0) {
|
|
1204
1270
|
return style.midnight;
|
|
1205
1271
|
}
|
|
@@ -1211,9 +1277,11 @@ function twelveHourTime(
|
|
|
1211
1277
|
|
|
1212
1278
|
// `hour`/`minute` may be raw field tokens; the arithmetic below coerces
|
|
1213
1279
|
// them numerically, matching `clockDigits`. Cast for the modulo/compare.
|
|
1280
|
+
// `explicit` keeps the minute (":00") rather than leaning down to the bare
|
|
1281
|
+
// hour, so a pinned minute-0 stays visible.
|
|
1214
1282
|
const digits = clockDigits(
|
|
1215
1283
|
{hour: (hour as number) % 12 || 12, minute: minute as number, second},
|
|
1216
|
-
{lean:
|
|
1284
|
+
{lean: !explicit, sep: style.sep});
|
|
1217
1285
|
|
|
1218
1286
|
return digits + (style.closeUp ? '' : ' ') +
|
|
1219
1287
|
((hour as number) < 12 ? style.am : style.pm);
|
|
@@ -1255,11 +1323,10 @@ function getOrdinal(n: number | string): string {
|
|
|
1255
1323
|
return n + suffix;
|
|
1256
1324
|
}
|
|
1257
1325
|
|
|
1258
|
-
// Get English month names from a number
|
|
1326
|
+
// Get English month names from a canonical month number (months are never
|
|
1327
|
+
// Quartz, so the field is always number-canonicalized by the core).
|
|
1259
1328
|
function getMonth(m: number | string, opts: NormalizedOptions): string {
|
|
1260
|
-
|
|
1261
|
-
// (indexing `monthAbbreviations`); the unmatched table yields undefined.
|
|
1262
|
-
const month = monthNames[m as number] || monthAbbreviations[m];
|
|
1329
|
+
const month = monthNames[+m];
|
|
1263
1330
|
|
|
1264
1331
|
// A valid month always resolves to a name pair, so the guarded lookup is
|
|
1265
1332
|
// a string; the cast keeps the original null-guard expression intact.
|
|
@@ -1287,7 +1354,10 @@ const en: Language = {
|
|
|
1287
1354
|
fallback: 'an unrecognizable cron pattern',
|
|
1288
1355
|
options: normalizeOptions,
|
|
1289
1356
|
reboot: 'at system startup',
|
|
1290
|
-
|
|
1357
|
+
// A description ending in an abbreviation already carries its period
|
|
1358
|
+
// ("…9 a.m."), so closing the sentence must not double it.
|
|
1359
|
+
sentence: (description) =>
|
|
1360
|
+
'Runs ' + description + (description.endsWith('.') ? '' : '.')
|
|
1291
1361
|
};
|
|
1292
1362
|
|
|
1293
1363
|
export default en;
|