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/en/index.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// the core stays semantic, and this module's only input is the IR.
|
|
4
4
|
// See docs/i18n-design.md.
|
|
5
5
|
|
|
6
|
+
import {arithmeticStep} from '../../core/util.js';
|
|
7
|
+
import {maxClockTimes} from '../../core/specs.js';
|
|
6
8
|
import {clockDigits, numeral} from '../../core/format.js';
|
|
7
9
|
import type {Cronli5Options} from '../../types.js';
|
|
8
10
|
import type {
|
|
@@ -19,14 +21,29 @@ type PlanOf<K extends PlanNode['kind']> = Extract<PlanNode, {kind: K}>;
|
|
|
19
21
|
// phrasing, where the first segment is always a step segment.
|
|
20
22
|
type StepSegment = Extract<Segment, {kind: 'step'}>;
|
|
21
23
|
|
|
24
|
+
// A step cadence to phrase: the `interval` repeats over a `cycle`-long field
|
|
25
|
+
// (60 for minute/second, 24 for hour), running from `start` to `last`. `unit`
|
|
26
|
+
// is the singular noun and `anchor` the larger unit the values count against.
|
|
27
|
+
interface Stride {
|
|
28
|
+
interval: number;
|
|
29
|
+
start: number;
|
|
30
|
+
last: number;
|
|
31
|
+
cycle: number;
|
|
32
|
+
unit: string;
|
|
33
|
+
anchor: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
// A clock-time entry assembled for rendering. Hour/minute/second arrive as
|
|
23
37
|
// numbers or as raw field tokens (a range bound or single value is a
|
|
24
|
-
// string); `plain` suppresses the noon/midnight words.
|
|
38
|
+
// string); `plain` suppresses the noon/midnight words. `explicit` forces the
|
|
39
|
+
// minute to show even when zero ("9:00 a.m.", not "9 a.m.") and suppresses
|
|
40
|
+
// the noon/midnight words, so a pinned minute-0 stays visible.
|
|
25
41
|
interface TimeEntry {
|
|
26
42
|
hour: number | string;
|
|
27
43
|
minute: number | string;
|
|
28
44
|
second?: number | string | null;
|
|
29
45
|
plain?: boolean;
|
|
46
|
+
explicit?: boolean;
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
// English number names for the integers zero through ten.
|
|
@@ -80,22 +97,6 @@ const weekdayNames: [string, string][] = [
|
|
|
80
97
|
['Saturday', 'Sat']
|
|
81
98
|
];
|
|
82
99
|
|
|
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
100
|
// Weekday name by abbreviation.
|
|
100
101
|
const weekdayAbbreviations: Record<string, [string, string]> = {
|
|
101
102
|
SUN: weekdayNames[0],
|
|
@@ -187,18 +188,121 @@ function renderSecondsWithinMinute(ir: IR, plan: PlanOf<'secondsWithinMinute'>,
|
|
|
187
188
|
trailingQualifier(ir, opts);
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
// The hour-cadence rendering of a compose-seconds plan whose clock-time rest
|
|
192
|
+
// would cross-multiply an hour stride under a single pinned minute, or null
|
|
193
|
+
// when that does not apply (a non-clock rest, a multi-valued minute, or an
|
|
194
|
+
// hour that is not a stride).
|
|
195
|
+
function composeHourCadence(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
196
|
+
opts: NormalizedOptions): string | null {
|
|
197
|
+
const clockRest = plan.rest.kind === 'clockTimes' ||
|
|
198
|
+
plan.rest.kind === 'compactClockTimes';
|
|
199
|
+
|
|
200
|
+
return clockRest && ir.shapes.minute === 'single' ?
|
|
201
|
+
hourCadence(ir, +ir.pattern.minute, opts) :
|
|
202
|
+
null;
|
|
203
|
+
}
|
|
204
|
+
|
|
190
205
|
// A meaningful second under minute/hour shapes the earlier strategies
|
|
191
206
|
// deferred on: the second leads with its own clause and the rest of the
|
|
192
207
|
// pattern follows.
|
|
193
208
|
function renderComposeSeconds(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
194
209
|
opts: NormalizedOptions): string {
|
|
210
|
+
// An hour step (or arithmetic-progression hour list) under a single pinned
|
|
211
|
+
// minute is a cadence, not a wall of clock times: speak the second/minute
|
|
212
|
+
// lead, then the hour cadence ("at 30 seconds past the hour, every two
|
|
213
|
+
// hours"). The clock-time rest would otherwise cross-multiply the hours.
|
|
214
|
+
const cadence = composeHourCadence(ir, plan, opts);
|
|
215
|
+
|
|
216
|
+
if (cadence !== null) {
|
|
217
|
+
return cadence;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A wildcard or stepped second under a minute pinned to a single value
|
|
221
|
+
// across one or more specific hours. The clock-time rest collapses the
|
|
222
|
+
// pinned minute into the hour, and on the clock a pinned minute-0 reads as
|
|
223
|
+
// the whole hour ("9 a.m." spoken == "9:00 a.m."), losing the one-minute
|
|
224
|
+
// confinement. (A second list/range/single leads with a "past the minute"
|
|
225
|
+
// clause that an "of"/duration frame cannot follow, so it stays generic.)
|
|
226
|
+
if (plan.rest.kind === 'clockTimes' &&
|
|
227
|
+
(ir.shapes.second === 'wildcard' || ir.shapes.second === 'step')) {
|
|
228
|
+
const minute = plan.rest.times[0].minute;
|
|
229
|
+
|
|
230
|
+
// Minute 0 is the one-minute window at the top of each named hour: a
|
|
231
|
+
// duration frame ("for one minute at 9 a.m.") states the confinement
|
|
232
|
+
// outright, with the hour as its word so it cannot be heard as the hour
|
|
233
|
+
// itself. A non-zero pinned minute is an unambiguous clock time, so the
|
|
234
|
+
// compact "of 9:05 a.m." form reads it as the minute, never the hour.
|
|
235
|
+
if (+minute === 0) {
|
|
236
|
+
return secondsLeadClause(ir, opts) + ' for one minute at ' +
|
|
237
|
+
durationHours(ir, plan.rest, opts);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return secondsLeadClause(ir, opts) + ' of ' +
|
|
241
|
+
clockTimesOf(ir, plan.rest, opts);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// A wildcard second under a */2 minute step with a wildcard hour binds
|
|
245
|
+
// idiomatically as "every second of every other minute": "every other" is
|
|
246
|
+
// the natural English for an interval of 2, and "of" joins the two without
|
|
247
|
+
// the ambiguity of a comma, which reads as two independent cadences.
|
|
248
|
+
// Scoped to */2 only; other step sizes keep the comma form.
|
|
249
|
+
if (ir.shapes.second === 'wildcard' &&
|
|
250
|
+
plan.rest.kind === 'minuteFrequency' &&
|
|
251
|
+
plan.rest.hours.kind === 'none' &&
|
|
252
|
+
ir.pattern.minute === '*/2') {
|
|
253
|
+
return 'every second of every other minute' +
|
|
254
|
+
trailingQualifier(ir, opts);
|
|
255
|
+
}
|
|
256
|
+
|
|
195
257
|
return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
|
|
196
258
|
}
|
|
197
259
|
|
|
260
|
+
// The bare-hour words for a minute-0 duration confinement, joined and followed
|
|
261
|
+
// by the trailing day qualifier: "9 a.m. and 11 a.m., every day", "midnight,
|
|
262
|
+
// 2 a.m., …, every day". The hour reads as its word (noon/midnight included),
|
|
263
|
+
// never "H:00", since the "for one minute" frame already carries the minute.
|
|
264
|
+
function durationHours(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
265
|
+
opts: NormalizedOptions): string {
|
|
266
|
+
const hours = plan.times.map(function clock(time) {
|
|
267
|
+
return getTime({hour: time.hour, minute: 0}, opts);
|
|
268
|
+
});
|
|
269
|
+
const trail = dayQualifier(ir, leadingWords, opts);
|
|
270
|
+
|
|
271
|
+
return joinList(hours, opts) + (trail && ', ' + trail);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The clock times for a non-zero pinned-minute compose-seconds rest, joined
|
|
275
|
+
// and followed by the trailing day qualifier: "9:05 a.m. and 11:05 a.m.,
|
|
276
|
+
// every day". The non-zero minute reads as a clock time, never the hour.
|
|
277
|
+
function clockTimesOf(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
278
|
+
opts: NormalizedOptions): string {
|
|
279
|
+
const times = plan.times.map(function clock(time) {
|
|
280
|
+
return getTime({
|
|
281
|
+
hour: time.hour,
|
|
282
|
+
minute: time.minute,
|
|
283
|
+
second: time.second,
|
|
284
|
+
explicit: true
|
|
285
|
+
}, opts);
|
|
286
|
+
});
|
|
287
|
+
const trail = dayQualifier(ir, leadingWords, opts);
|
|
288
|
+
|
|
289
|
+
return joinList(times, opts) + (trail && ', ' + trail);
|
|
290
|
+
}
|
|
291
|
+
|
|
198
292
|
// The leading clause describing a second field relative to the minute,
|
|
199
293
|
// e.g. "at 5 and 10 seconds past the minute" or "every second from zero
|
|
200
294
|
// through 30 past the minute".
|
|
201
295
|
function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
|
|
296
|
+
return secondsClause(ir, 'minute', opts);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The second clause counted against an arbitrary anchor. The anchor is
|
|
300
|
+
// "minute" in the standalone seconds path; the hour-cadence path folds a
|
|
301
|
+
// pinned minute 0 into the hour and counts the second "past the hour"
|
|
302
|
+
// instead ("at 30 seconds past the hour", "every second from 0 through 10
|
|
303
|
+
// past the hour"), so the minute-0 confinement is stated, not dropped.
|
|
304
|
+
function secondsClause(ir: IR, anchor: string,
|
|
305
|
+
opts: NormalizedOptions): string {
|
|
202
306
|
const secondField = ir.pattern.second;
|
|
203
307
|
const shape = ir.shapes.second;
|
|
204
308
|
|
|
@@ -210,7 +314,7 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
|
|
|
210
314
|
// The plan reached this clause only for a stepped second field, whose
|
|
211
315
|
// first segment is always a step segment.
|
|
212
316
|
return stepCycle60(ir.analyses.segments.second![0] as StepSegment,
|
|
213
|
-
'second',
|
|
317
|
+
'second', anchor, opts);
|
|
214
318
|
}
|
|
215
319
|
|
|
216
320
|
if (shape === 'range') {
|
|
@@ -218,17 +322,20 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
|
|
|
218
322
|
const num = seriesNumber(bounds, opts);
|
|
219
323
|
|
|
220
324
|
return 'every second from ' + num(bounds[0]) +
|
|
221
|
-
through(opts) + num(bounds[1]) + ' past the
|
|
325
|
+
through(opts) + num(bounds[1]) + ' past the ' + anchor;
|
|
222
326
|
}
|
|
223
327
|
|
|
224
328
|
if (shape === 'single') {
|
|
225
329
|
return 'at ' + getNumber(secondField, opts) + ' ' +
|
|
226
|
-
pluralize(secondField, 'second') + ' past the
|
|
330
|
+
pluralize(secondField, 'second') + ' past the ' + anchor;
|
|
227
331
|
}
|
|
228
332
|
|
|
229
|
-
// A non-wildcard second under the list/step path always has segments.
|
|
230
|
-
|
|
231
|
-
|
|
333
|
+
// A non-wildcard second under the list/step path always has segments. An
|
|
334
|
+
// offset/uneven step the core enumerated to a fire list reads as a stride
|
|
335
|
+
// cadence when those fires form a long-enough progression.
|
|
336
|
+
return strideFromSegments(ir.analyses.segments.second!, 'second', anchor,
|
|
337
|
+
opts) ?? listPastThe(segmentWords(ir.analyses.segments.second!, opts),
|
|
338
|
+
'second', anchor, opts);
|
|
232
339
|
}
|
|
233
340
|
|
|
234
341
|
// --- Minute renderers. ---
|
|
@@ -256,9 +363,13 @@ function renderRangeOfMinutes(ir: IR, plan: PlanOf<'rangeOfMinutes'>,
|
|
|
256
363
|
function renderMultipleMinutes(ir: IR, plan: PlanOf<'multipleMinutes'>,
|
|
257
364
|
opts: NormalizedOptions): string {
|
|
258
365
|
// A multiple-minutes plan is selected only for a minute list, which has
|
|
259
|
-
// segments.
|
|
260
|
-
|
|
261
|
-
|
|
366
|
+
// segments. An offset/uneven step the core enumerated to this list reads as
|
|
367
|
+
// a stride cadence when the fires form a long-enough progression.
|
|
368
|
+
const stride =
|
|
369
|
+
strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts);
|
|
370
|
+
|
|
371
|
+
return (stride ?? listPastThe(segmentWords(ir.analyses.segments.minute!,
|
|
372
|
+
opts), 'minute', 'hour', opts)) + trailingQualifier(ir, opts);
|
|
262
373
|
}
|
|
263
374
|
|
|
264
375
|
// A repeating minute step, qualified by the active hour window(s).
|
|
@@ -289,9 +400,18 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
|
|
|
289
400
|
}
|
|
290
401
|
|
|
291
402
|
// A minute wildcard or plain range under a single specific hour fires
|
|
292
|
-
// every minute within a window inside that hour.
|
|
403
|
+
// every minute within a window inside that hour. A wildcard minute is the
|
|
404
|
+
// whole hour, so it reads as that hour itself ("every minute of the 9 a.m.
|
|
405
|
+
// hour") rather than a synthesized "from H:00 through H:59" range the source
|
|
406
|
+
// never stated; a plain range is a real window and keeps "from … through …".
|
|
293
407
|
function renderMinuteSpanInHour(ir: IR, plan: PlanOf<'minuteSpanInHour'>,
|
|
294
408
|
opts: NormalizedOptions): string {
|
|
409
|
+
if (ir.pattern.minute === '*') {
|
|
410
|
+
return 'every minute of the ' +
|
|
411
|
+
getTime({hour: plan.hour, minute: 0}, opts) + ' hour' +
|
|
412
|
+
trailingQualifier(ir, opts);
|
|
413
|
+
}
|
|
414
|
+
|
|
295
415
|
return 'every minute from ' +
|
|
296
416
|
getTime({hour: plan.hour, minute: plan.span[0]}, opts) +
|
|
297
417
|
through(opts) + getTime({hour: plan.hour, minute: plan.span[1]}, opts) +
|
|
@@ -311,9 +431,11 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
|
|
|
311
431
|
const times = hourTimesFromPlan(ir, plan.times, true, opts);
|
|
312
432
|
const lead = plan.form === 'range' ?
|
|
313
433
|
minuteRangeLead(ir.pattern.minute, opts) :
|
|
314
|
-
// The 'list' form is a minute list, which has segments
|
|
315
|
-
|
|
316
|
-
|
|
434
|
+
// The 'list' form is a minute list, which has segments; an offset/uneven
|
|
435
|
+
// step enumerated to that list reads as a stride.
|
|
436
|
+
strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
|
|
437
|
+
listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
|
|
438
|
+
'minute', 'hour', opts);
|
|
317
439
|
|
|
318
440
|
return lead + ', at ' + times + trailingQualifier(ir, opts);
|
|
319
441
|
}
|
|
@@ -349,8 +471,15 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
|
|
|
349
471
|
trailingQualifier(ir, opts);
|
|
350
472
|
}
|
|
351
473
|
|
|
352
|
-
|
|
353
|
-
|
|
474
|
+
// A minute list keeps the same cadence clause; only its lead differs. An
|
|
475
|
+
// offset/uneven step the core enumerated to that list reads as a stride.
|
|
476
|
+
const lead = plan.form === 'list' ?
|
|
477
|
+
strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
|
|
478
|
+
listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
|
|
479
|
+
'minute', 'hour', opts) :
|
|
480
|
+
minuteRangeLead(ir.pattern.minute, opts);
|
|
481
|
+
|
|
482
|
+
return lead + ', ' + stepHours(segment, opts) + trailingQualifier(ir, opts);
|
|
354
483
|
}
|
|
355
484
|
|
|
356
485
|
// Lead phrase for a plain minute range: "every minute from <a> through <b>
|
|
@@ -398,8 +527,10 @@ function rangeMinuteLead(ir: IR, opts: NormalizedOptions): string {
|
|
|
398
527
|
return 'every hour';
|
|
399
528
|
}
|
|
400
529
|
|
|
401
|
-
// A non-"0" minute here is a discrete list, which has segments
|
|
402
|
-
|
|
530
|
+
// A non-"0" minute here is a discrete list, which has segments; an
|
|
531
|
+
// offset/uneven step enumerated to that list reads as a stride.
|
|
532
|
+
return strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour',
|
|
533
|
+
opts) ?? listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
|
|
403
534
|
'minute', 'hour', opts);
|
|
404
535
|
}
|
|
405
536
|
|
|
@@ -432,6 +563,16 @@ function hourWindow(window: {from: number; to: number; last: number},
|
|
|
432
563
|
// a day-level qualifier, e.g. "every day at 9 a.m. and 9:30 a.m.".
|
|
433
564
|
function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
434
565
|
opts: NormalizedOptions): string {
|
|
566
|
+
// An hour step (or arithmetic-progression hour list) under a single pinned
|
|
567
|
+
// minute reads as a cadence rather than a cross-product of clock times.
|
|
568
|
+
if (ir.shapes.minute === 'single') {
|
|
569
|
+
const cadence = hourCadence(ir, +ir.pattern.minute, opts);
|
|
570
|
+
|
|
571
|
+
if (cadence !== null) {
|
|
572
|
+
return cadence;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
435
576
|
const plain = mixedTwelve(plan.times);
|
|
436
577
|
const times = plan.times.map(function clock(time) {
|
|
437
578
|
return getTime({
|
|
@@ -451,6 +592,15 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
|
451
592
|
function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
452
593
|
opts: NormalizedOptions): string {
|
|
453
594
|
if (plan.fold) {
|
|
595
|
+
// An hour step (or arithmetic-progression hour list) under the single
|
|
596
|
+
// pinned minute reads as a cadence, not a wall of clock times. (Returns
|
|
597
|
+
// null for an irregular list or a range, which keep folding below.)
|
|
598
|
+
const cadence = hourCadence(ir, +plan.minute, opts);
|
|
599
|
+
|
|
600
|
+
if (cadence !== null) {
|
|
601
|
+
return cadence;
|
|
602
|
+
}
|
|
603
|
+
|
|
454
604
|
// A compact clock-time plan is reached only for discrete hours, which
|
|
455
605
|
// have segments.
|
|
456
606
|
const hasRange = ir.analyses.segments.hour!.some(function range(segment) {
|
|
@@ -470,9 +620,11 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
|
470
620
|
}
|
|
471
621
|
|
|
472
622
|
const phrase =
|
|
473
|
-
// The non-fold branch is a minute list, which has segments.
|
|
474
|
-
|
|
475
|
-
|
|
623
|
+
// The non-fold branch is a minute list, which has segments. An
|
|
624
|
+
// offset/uneven step enumerated to that list reads as a stride.
|
|
625
|
+
(strideFromSegments(ir.analyses.segments.minute!, 'minute', 'hour', opts) ??
|
|
626
|
+
listPastThe(segmentWords(ir.analyses.segments.minute!, opts),
|
|
627
|
+
'minute', 'hour', opts)) +
|
|
476
628
|
', at ' + hourSegmentTimes(ir, {minute: 0, second: null}, true, opts) +
|
|
477
629
|
trailingQualifier(ir, opts);
|
|
478
630
|
|
|
@@ -544,6 +696,71 @@ const renderers = {
|
|
|
544
696
|
|
|
545
697
|
// --- Step phrases. ---
|
|
546
698
|
|
|
699
|
+
// Speak a step cadence over a `cycle`-long field ("every N <unit>s [from M
|
|
700
|
+
// [through K]] past the <anchor>"). A clean stride from the top of the cycle
|
|
701
|
+
// is the bare cadence; a uniform offset (start within the first interval, the
|
|
702
|
+
// interval still tiling the cycle) names only its start, since it wraps cleanly
|
|
703
|
+
// and has no distinct endpoint; a non-uniform stride (start >= interval, or an
|
|
704
|
+
// interval that does not tile the cycle) pins both endpoints so the bounded,
|
|
705
|
+
// non-wrapping set reads unambiguously. This is the one phrasing for every
|
|
706
|
+
// step the renderer speaks, whether the core kept it a step shape (a clean
|
|
707
|
+
// cadence) or enumerated it to a fire list (an offset/uneven set the list
|
|
708
|
+
// path recognizes as an arithmetic progression).
|
|
709
|
+
function renderStride(stride: Stride, opts: NormalizedOptions): string {
|
|
710
|
+
const {interval, start, last, cycle, unit, anchor} = stride;
|
|
711
|
+
const cadence = 'every ' + getNumber(interval, opts) + ' ' + unit + 's';
|
|
712
|
+
const tiles = cycle % interval === 0;
|
|
713
|
+
|
|
714
|
+
if (start === 0 && tiles) {
|
|
715
|
+
return cadence;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (start < interval && tiles) {
|
|
719
|
+
// A clean wrap from a non-zero offset: name the start, no endpoint.
|
|
720
|
+
return cadence + ' from ' + getNumber(start, opts) + ' ' +
|
|
721
|
+
pluralize(start, unit) + ' past the ' + anchor;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// A bounded, non-wrapping set: pin both endpoints. The two bounds share one
|
|
725
|
+
// number style (all spelled, or all numerals once either crosses ten),
|
|
726
|
+
// matching the range idiom ("from 0 through 30").
|
|
727
|
+
const num = seriesNumber([start, last], opts);
|
|
728
|
+
|
|
729
|
+
return cadence + ' from ' + num(start) + through(opts) + num(last) + ' ' +
|
|
730
|
+
pluralize(last, unit) + ' past the ' + anchor;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// The sorted numeric values a field's segments cover, or null if any segment
|
|
734
|
+
// is not a discrete single (a range or sub-step is not a plain fire list).
|
|
735
|
+
function singleValues(segments: Segment[]): number[] | null {
|
|
736
|
+
const values: number[] = [];
|
|
737
|
+
|
|
738
|
+
for (const segment of segments) {
|
|
739
|
+
if (segment.kind !== 'single') {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
values.push(+segment.value);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return values;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Speak a minute/second field's enumerated fires as a step cadence when they
|
|
750
|
+
// form an arithmetic progression long enough to beat the list (the core
|
|
751
|
+
// enumerates an offset/uneven step to this fire list; the IR is unchanged, so
|
|
752
|
+
// the renderer recognizes the progression). Returns null for a non-progression
|
|
753
|
+
// or a too-short list, leaving the caller to enumerate.
|
|
754
|
+
function strideFromSegments(segments: Segment[], unit: string, anchor: string,
|
|
755
|
+
opts: NormalizedOptions): string | null {
|
|
756
|
+
const values = singleValues(segments);
|
|
757
|
+
const step = values && arithmeticStep(values);
|
|
758
|
+
|
|
759
|
+
return step ?
|
|
760
|
+
renderStride({...step, cycle: 60, unit, anchor}, opts) :
|
|
761
|
+
null;
|
|
762
|
+
}
|
|
763
|
+
|
|
547
764
|
// Phrase a `start/interval` step segment for a field that cycles every 60
|
|
548
765
|
// units (seconds and minutes). `unit` is the singular noun and `anchor` is
|
|
549
766
|
// the larger unit the values are counted against. Interval-one steps never
|
|
@@ -559,23 +776,23 @@ function stepCycle60(segment: StepSegment, unit: string,
|
|
|
559
776
|
}
|
|
560
777
|
|
|
561
778
|
const start = segment.startToken === '*' ? 0 : +segment.startToken;
|
|
562
|
-
const interval = segment.interval;
|
|
563
779
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
return 'every ' + getNumber(interval, opts) + ' ' + unit + 's from ' +
|
|
573
|
-
getNumber(start, opts) + ' ' + pluralize(start, unit) +
|
|
574
|
-
' past the ' + anchor;
|
|
780
|
+
// A short offset cadence lists its fires; otherwise the stride phrasing
|
|
781
|
+
// names the interval and its offset ("every six minutes from five …"). A
|
|
782
|
+
// step shape only reaches here as a clean cadence (the interval tiles 60),
|
|
783
|
+
// so the stride collapses to the bare or uniform-offset form.
|
|
784
|
+
if (start !== 0 && segment.fires.length <= 3) {
|
|
785
|
+
return listPastThe(numberWords(segment.fires, opts), unit, anchor, opts);
|
|
575
786
|
}
|
|
576
787
|
|
|
577
|
-
|
|
578
|
-
|
|
788
|
+
return renderStride({
|
|
789
|
+
interval: segment.interval,
|
|
790
|
+
start,
|
|
791
|
+
last: segment.fires[segment.fires.length - 1],
|
|
792
|
+
cycle: 60,
|
|
793
|
+
unit,
|
|
794
|
+
anchor
|
|
795
|
+
}, opts);
|
|
579
796
|
}
|
|
580
797
|
|
|
581
798
|
// Phrase a `start/interval` step segment for the hour field (cycles every
|
|
@@ -605,6 +822,154 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
|
|
|
605
822
|
getTime({hour: start, minute: 0}, opts);
|
|
606
823
|
}
|
|
607
824
|
|
|
825
|
+
// Speak an hour stride as a cadence with clock-time bounds, the 24-cycle
|
|
826
|
+
// analog of renderStride: a clean stride from midnight is the bare cadence
|
|
827
|
+
// ("every two hours"); a clean offset names only its start ("every six hours
|
|
828
|
+
// from 2 a.m."); a bounded or non-tiling stride pins both clock-time endpoints
|
|
829
|
+
// ("every two hours from 9 a.m. through 5 p.m.") so the bounded set reads
|
|
830
|
+
// unambiguously. Used wherever an hour step (or arithmetic-progression hour
|
|
831
|
+
// list) would otherwise be cross-multiplied into a wall of clock times.
|
|
832
|
+
function hourStrideCadence(stride: {start: number; interval: number;
|
|
833
|
+
last: number}, opts: NormalizedOptions): string {
|
|
834
|
+
const {start, interval, last} = stride;
|
|
835
|
+
const cadence = 'every ' + getNumber(interval, opts) + ' hours';
|
|
836
|
+
const tiles = 24 % interval === 0;
|
|
837
|
+
|
|
838
|
+
if (start === 0 && tiles) {
|
|
839
|
+
return cadence;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (start < interval && tiles) {
|
|
843
|
+
return cadence + ' from ' + getTime({hour: start, minute: 0}, opts);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return cadence + ' from ' + getTime({hour: start, minute: 0}, opts) +
|
|
847
|
+
through(opts) + getTime({hour: last, minute: 0}, opts);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// The hour field's stride, or null when the hour is not a cadence: a step
|
|
851
|
+
// segment yields its {start, interval, last} directly; an all-single hour
|
|
852
|
+
// list yields one only when its values form a long-enough arithmetic
|
|
853
|
+
// progression (so an irregular list like 9,17 keeps enumerating). The IR is
|
|
854
|
+
// unchanged — the renderer recognizes the stride and speaks it as a cadence
|
|
855
|
+
// instead of the clock-time cross-product.
|
|
856
|
+
function hourStride(ir: IR):
|
|
857
|
+
{start: number; interval: number; last: number} | null {
|
|
858
|
+
// Reached only from the clock-time paths, which run under discrete hours
|
|
859
|
+
// and so always carry hour segments.
|
|
860
|
+
const segments = ir.analyses.segments.hour!;
|
|
861
|
+
|
|
862
|
+
if (segments.length === 1 && segments[0].kind === 'step') {
|
|
863
|
+
const segment = segments[0];
|
|
864
|
+
const start = segment.startToken === '*' ?
|
|
865
|
+
0 :
|
|
866
|
+
+segment.startToken.split('-')[0];
|
|
867
|
+
|
|
868
|
+
return {interval: segment.interval, last: segment.fires[
|
|
869
|
+
segment.fires.length - 1], start};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const values = singleValues(segments);
|
|
873
|
+
const step = values && arithmeticStep(values);
|
|
874
|
+
|
|
875
|
+
return step || null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// The second's status against a pinned minute: a wildcard or sub-minute step
|
|
879
|
+
// fills the minute (a "for one minute" frame at minute 0); a single 0 is just
|
|
880
|
+
// the top of the minute (no clause); anything else needs its own clause.
|
|
881
|
+
function subMinuteSecond(ir: IR): boolean {
|
|
882
|
+
return ir.pattern.second === '*' || ir.shapes.second === 'step';
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// The lead clause for an hour-cadence rendering: the second and the pinned
|
|
886
|
+
// minute, before the hour cadence. A pinned minute 0 folds in — a single,
|
|
887
|
+
// list, or range second is counted "past the hour" (the minute-0 is the top
|
|
888
|
+
// of the hour), and a wildcard or sub-minute step second takes a "for one
|
|
889
|
+
// minute" frame (the whole minute-0 window). A non-zero minute is a real
|
|
890
|
+
// clock minute: the second leads with its own "past the minute" clause (if
|
|
891
|
+
// any), then the minute reads "M minutes past the hour".
|
|
892
|
+
function hourCadenceLead(ir: IR, minute: number,
|
|
893
|
+
opts: NormalizedOptions): string {
|
|
894
|
+
if (minute === 0) {
|
|
895
|
+
if (subMinuteSecond(ir)) {
|
|
896
|
+
return secondsClause(ir, 'minute', opts) + ' for one minute';
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return secondsClause(ir, 'hour', opts);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const minutePhrase = getNumber(minute, opts) + ' ' +
|
|
903
|
+
pluralize(minute, 'minute') + ' past the hour';
|
|
904
|
+
|
|
905
|
+
// A single 0 second is just the top of the minute, so the minute leads
|
|
906
|
+
// alone; any other second prefixes its own clause.
|
|
907
|
+
if (ir.pattern.second === '0') {
|
|
908
|
+
return minutePhrase;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return secondsClause(ir, 'minute', opts) + ', ' + minutePhrase;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Render an hour step (or arithmetic-progression hour list) under a single
|
|
915
|
+
// pinned minute and a second as a cadence — the lead clause, then the hour
|
|
916
|
+
// cadence — instead of cross-multiplying the hours into a wall of clock
|
|
917
|
+
// times. Returns null when the hour is not a stride (an irregular list, a
|
|
918
|
+
// single hour, or a range), or when the cross-product is short enough that
|
|
919
|
+
// enumeration is no longer than the cadence: a meaningful second (anything
|
|
920
|
+
// but a plain :00) makes every clock time three digit-groups, so any stride
|
|
921
|
+
// is worth compacting; otherwise the stride must exceed the clock-time cap,
|
|
922
|
+
// the same point at which the core itself stops enumerating. Renderer-only;
|
|
923
|
+
// the IR is unchanged.
|
|
924
|
+
function hourCadence(ir: IR, minute: number,
|
|
925
|
+
opts: NormalizedOptions): string | null {
|
|
926
|
+
const stride = hourStride(ir);
|
|
927
|
+
|
|
928
|
+
if (!stride) {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const fires = (stride.last - stride.start) / stride.interval + 1;
|
|
933
|
+
|
|
934
|
+
if (ir.pattern.second === '0' && fires <= maxClockTimes) {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// A wildcard or sub-minute step second confined to minute 0 of a clean
|
|
939
|
+
// hour stride is a confinement, not a juxtaposed cadence: it reads "for one
|
|
940
|
+
// minute during every other hour", matching the "every minute during every
|
|
941
|
+
// other hour" idiom and keeping it distinct from the bare hour-step form
|
|
942
|
+
// ("every two hours") so the minute-0 confinement is never heard as it.
|
|
943
|
+
const confinement = minute === 0 && subMinuteSecond(ir) &&
|
|
944
|
+
cleanStrideSegment(ir);
|
|
945
|
+
|
|
946
|
+
if (confinement) {
|
|
947
|
+
return secondsClause(ir, 'minute', opts) + ' for one minute ' +
|
|
948
|
+
everyNthHour(confinement, opts) + trailingQualifier(ir, opts);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return hourCadenceLead(ir, minute, opts) + ', ' +
|
|
952
|
+
hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// The hour step segment when the hour is a clean stride with an idiomatic
|
|
956
|
+
// ordinal ("every other", "every sixth"), suitable for the "during every Nth
|
|
957
|
+
// hour" confinement frame; null otherwise (an uneven stride, a bounded step,
|
|
958
|
+
// or an arithmetic-progression list, which keep the bounded cadence form).
|
|
959
|
+
function cleanStrideSegment(ir: IR): StepSegment | null {
|
|
960
|
+
// Reached only after hourStride confirmed a stride, so hour segments exist.
|
|
961
|
+
const segments = ir.analyses.segments.hour!;
|
|
962
|
+
const segment = segments.length === 1 && segments[0];
|
|
963
|
+
|
|
964
|
+
if (!segment || segment.kind !== 'step' ||
|
|
965
|
+
segment.startToken.indexOf('-') !== -1 ||
|
|
966
|
+
!(segment.interval in stepOrdinals)) {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return segment;
|
|
971
|
+
}
|
|
972
|
+
|
|
608
973
|
// --- List and segment phrasing. ---
|
|
609
974
|
|
|
610
975
|
// Chicago number style for a series: if any value crosses the spell-out
|
|
@@ -1169,7 +1534,7 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
|
|
|
1169
1534
|
// "3.45pm" / "9am" / "midday" for UK (Guardian), or "15:45" / "15.45" in
|
|
1170
1535
|
// 24-hour mode.
|
|
1171
1536
|
function getTime(time: TimeEntry, opts: NormalizedOptions): string {
|
|
1172
|
-
const {hour, minute, plain} = time;
|
|
1537
|
+
const {hour, minute, plain, explicit} = time;
|
|
1173
1538
|
// Seconds are only shown when a specific non-zero value is supplied.
|
|
1174
1539
|
const second = typeof time.second === 'number' && time.second > 0 ?
|
|
1175
1540
|
time.second :
|
|
@@ -1179,12 +1544,13 @@ function getTime(time: TimeEntry, opts: NormalizedOptions): string {
|
|
|
1179
1544
|
// Hour/minute arrive as numbers or raw field tokens (a range bound or
|
|
1180
1545
|
// single value is a string); `clockDigits` types them as numbers but
|
|
1181
1546
|
// `pad` stringifies either form to the same digits. Cast to keep the
|
|
1182
|
-
// value byte-identical rather than coercing it.
|
|
1547
|
+
// value byte-identical rather than coercing it. The 24-hour form always
|
|
1548
|
+
// shows the minute, so it is already explicit.
|
|
1183
1549
|
return clockDigits({hour: hour as number, minute: minute as number,
|
|
1184
1550
|
second}, {pad: true, sep: opts.style.sep});
|
|
1185
1551
|
}
|
|
1186
1552
|
|
|
1187
|
-
return twelveHourTime({hour, minute, second, plain}, opts);
|
|
1553
|
+
return twelveHourTime({hour, minute, second, plain, explicit}, opts);
|
|
1188
1554
|
}
|
|
1189
1555
|
|
|
1190
1556
|
// The 12-hour form of a clock time: "9:30 a.m.", "9 a.m." on the hour, or
|
|
@@ -1193,13 +1559,13 @@ function getTime(time: TimeEntry, opts: NormalizedOptions): string {
|
|
|
1193
1559
|
// stays in one number style.
|
|
1194
1560
|
function twelveHourTime(
|
|
1195
1561
|
time: {hour: number | string; minute: number | string; second: number;
|
|
1196
|
-
plain?: boolean},
|
|
1562
|
+
plain?: boolean; explicit?: boolean},
|
|
1197
1563
|
opts: NormalizedOptions
|
|
1198
1564
|
): string {
|
|
1199
|
-
const {hour, minute, second, plain} = time;
|
|
1565
|
+
const {hour, minute, second, plain, explicit} = time;
|
|
1200
1566
|
const style = opts.style;
|
|
1201
1567
|
|
|
1202
|
-
if (!plain && +minute === 0 && !second) {
|
|
1568
|
+
if (!plain && !explicit && +minute === 0 && !second) {
|
|
1203
1569
|
if (+hour === 0) {
|
|
1204
1570
|
return style.midnight;
|
|
1205
1571
|
}
|
|
@@ -1211,9 +1577,11 @@ function twelveHourTime(
|
|
|
1211
1577
|
|
|
1212
1578
|
// `hour`/`minute` may be raw field tokens; the arithmetic below coerces
|
|
1213
1579
|
// them numerically, matching `clockDigits`. Cast for the modulo/compare.
|
|
1580
|
+
// `explicit` keeps the minute (":00") rather than leaning down to the bare
|
|
1581
|
+
// hour, so a pinned minute-0 stays visible.
|
|
1214
1582
|
const digits = clockDigits(
|
|
1215
1583
|
{hour: (hour as number) % 12 || 12, minute: minute as number, second},
|
|
1216
|
-
{lean:
|
|
1584
|
+
{lean: !explicit, sep: style.sep});
|
|
1217
1585
|
|
|
1218
1586
|
return digits + (style.closeUp ? '' : ' ') +
|
|
1219
1587
|
((hour as number) < 12 ? style.am : style.pm);
|
|
@@ -1255,11 +1623,10 @@ function getOrdinal(n: number | string): string {
|
|
|
1255
1623
|
return n + suffix;
|
|
1256
1624
|
}
|
|
1257
1625
|
|
|
1258
|
-
// Get English month names from a number
|
|
1626
|
+
// Get English month names from a canonical month number (months are never
|
|
1627
|
+
// Quartz, so the field is always number-canonicalized by the core).
|
|
1259
1628
|
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];
|
|
1629
|
+
const month = monthNames[+m];
|
|
1263
1630
|
|
|
1264
1631
|
// A valid month always resolves to a name pair, so the guarded lookup is
|
|
1265
1632
|
// a string; the cast keeps the original null-guard expression intact.
|
|
@@ -1287,7 +1654,10 @@ const en: Language = {
|
|
|
1287
1654
|
fallback: 'an unrecognizable cron pattern',
|
|
1288
1655
|
options: normalizeOptions,
|
|
1289
1656
|
reboot: 'at system startup',
|
|
1290
|
-
|
|
1657
|
+
// A description ending in an abbreviation already carries its period
|
|
1658
|
+
// ("…9 a.m."), so closing the sentence must not double it.
|
|
1659
|
+
sentence: (description) =>
|
|
1660
|
+
'Runs ' + description + (description.endsWith('.') ? '' : '.')
|
|
1291
1661
|
};
|
|
1292
1662
|
|
|
1293
1663
|
export default en;
|