cronli5 0.1.7 → 0.2.1
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 +68 -0
- package/README.md +2 -2
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +451 -144
- package/dist/cronli5.js +451 -144
- package/dist/lang/de.cjs +65 -65
- package/dist/lang/de.js +65 -65
- package/dist/lang/en.cjs +450 -141
- package/dist/lang/en.js +450 -141
- package/dist/lang/es.cjs +71 -72
- package/dist/lang/es.js +71 -72
- package/dist/lang/fi.cjs +71 -66
- package/dist/lang/fi.js +71 -66
- package/dist/lang/zh.cjs +36 -36
- package/dist/lang/zh.js +36 -36
- package/package.json +1 -1
- package/src/core/analyze.ts +14 -13
- package/src/core/ir.ts +13 -8
- package/src/core/shapes.ts +8 -1
- package/src/core/util.ts +86 -3
- package/src/core/validate.ts +1 -1
- package/src/cronli5.ts +3 -3
- package/src/lang/de/index.ts +30 -99
- package/src/lang/en/dialects.ts +6 -2
- package/src/lang/en/index.ts +820 -212
- package/src/lang/es/index.ts +36 -120
- package/src/lang/fi/index.ts +33 -104
- package/src/lang/zh/index.ts +23 -48
- package/src/types.ts +2 -2
- package/types/core/analyze.d.ts +2 -2
- package/types/core/ir.d.ts +8 -7
- package/types/core/shapes.d.ts +2 -1
- package/types/core/util.d.ts +17 -2
- package/types/types.d.ts +1 -1
package/src/lang/en/index.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
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 {
|
|
6
|
+
import {
|
|
7
|
+
arithmeticStep, hourListStride, offsetCleanStride, orderWeekdaysForDisplay,
|
|
8
|
+
segmentsOf, singleValues, stepSegment
|
|
9
|
+
} from '../../core/util.js';
|
|
10
|
+
import {isOpenStep} from '../../core/shapes.js';
|
|
7
11
|
import {maxClockTimes} from '../../core/specs.js';
|
|
8
|
-
import {clockDigits, numeral} from '../../core/format.js';
|
|
12
|
+
import {clockDigits, numeral, pad} from '../../core/format.js';
|
|
9
13
|
import type {Cronli5Options} from '../../types.js';
|
|
10
14
|
import type {
|
|
11
15
|
HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
|
|
@@ -33,6 +37,17 @@ interface Stride {
|
|
|
33
37
|
anchor: string;
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
// A contiguous hour range to phrase as a window. `from`/`to` are the bounding
|
|
41
|
+
// hours; `throughMinute` is the close minute used by the "through" span;
|
|
42
|
+
// `continuous` is true only when the run fills every minute of the final hour
|
|
43
|
+
// (a wildcard minute), which earns the default dialect's until-window.
|
|
44
|
+
interface HourWindowSpec {
|
|
45
|
+
from: number;
|
|
46
|
+
to: number;
|
|
47
|
+
throughMinute: number | string;
|
|
48
|
+
continuous: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
// A clock-time entry assembled for rendering. Hour/minute/second arrive as
|
|
37
52
|
// numbers or as raw field tokens (a range bound or single value is a
|
|
38
53
|
// string); `plain` suppresses the noon/midnight words. `explicit` forces the
|
|
@@ -130,7 +145,16 @@ function normalizeOptions(options?: Cronli5Options): NormalizedOptions {
|
|
|
130
145
|
|
|
131
146
|
// Render an analyzed cron pattern (the IR) as English.
|
|
132
147
|
function describe(ir: IR, opts: NormalizedOptions): string {
|
|
133
|
-
|
|
148
|
+
// A finer leading cadence puts each coarser field in the confinement frame,
|
|
149
|
+
// overriding the per-plan juxtaposed-cadence and duration-frame forms.
|
|
150
|
+
const body = confinement(ir, opts) ?? render(ir, ir.plan, opts);
|
|
151
|
+
|
|
152
|
+
// A day union scopes the whole clause by its month, which leads the
|
|
153
|
+
// description ("in June <time> whenever the day is …"); the time/cadence and
|
|
154
|
+
// the trailing condition are already in `body`.
|
|
155
|
+
const lead = isDayUnion(ir, opts) ? dayUnionMonthLead(ir, opts) : '';
|
|
156
|
+
|
|
157
|
+
return applyYear(lead + body, ir, opts);
|
|
134
158
|
}
|
|
135
159
|
|
|
136
160
|
// Render one plan node. `composeSeconds` recurses with its `rest` plan.
|
|
@@ -206,9 +230,6 @@ function composeHourCadence(ir: IR, plan: PlanOf<'composeSeconds'>,
|
|
|
206
230
|
return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
|
|
207
231
|
}
|
|
208
232
|
|
|
209
|
-
// A meaningful second under minute/hour shapes the earlier strategies
|
|
210
|
-
// deferred on: the second leads with its own clause and the rest of the
|
|
211
|
-
// pattern follows.
|
|
212
233
|
// A wildcard or stepped second under a fixed minute across one or more specific
|
|
213
234
|
// hours. The clock-time rest collapses the pinned minute into the hour, and on
|
|
214
235
|
// the clock a pinned minute-0 reads as the whole hour ("9 a.m." spoken ==
|
|
@@ -331,13 +352,13 @@ function secondsClause(ir: IR, anchor: string,
|
|
|
331
352
|
if (shape === 'step') {
|
|
332
353
|
// The plan reached this clause only for a stepped second field, whose
|
|
333
354
|
// first segment is always a step segment.
|
|
334
|
-
return stepCycle60(ir
|
|
355
|
+
return stepCycle60(stepSegment(ir, 'second'),
|
|
335
356
|
'second', anchor, opts);
|
|
336
357
|
}
|
|
337
358
|
|
|
338
359
|
if (shape === 'range') {
|
|
339
360
|
const bounds = secondField.split('-');
|
|
340
|
-
const num = seriesNumber(
|
|
361
|
+
const num = seriesNumber();
|
|
341
362
|
|
|
342
363
|
return 'every second from ' + num(bounds[0]) +
|
|
343
364
|
through(opts) + num(bounds[1]) + ' past the ' + anchor;
|
|
@@ -351,8 +372,8 @@ function secondsClause(ir: IR, anchor: string,
|
|
|
351
372
|
// A non-wildcard second under the list/step path always has segments. An
|
|
352
373
|
// offset/uneven step the core enumerated to a fire list reads as a stride
|
|
353
374
|
// cadence when those fires form a long-enough progression.
|
|
354
|
-
return strideFromSegments(ir
|
|
355
|
-
opts) ?? listPastThe(segmentWords(ir
|
|
375
|
+
return strideFromSegments(segmentsOf(ir, 'second'), 'second', anchor,
|
|
376
|
+
opts) ?? listPastThe(segmentWords(segmentsOf(ir, 'second'), opts),
|
|
356
377
|
'second', anchor, opts);
|
|
357
378
|
}
|
|
358
379
|
|
|
@@ -384,9 +405,9 @@ function renderMultipleMinutes(ir: IR, plan: PlanOf<'multipleMinutes'>,
|
|
|
384
405
|
// segments. An offset/uneven step the core enumerated to this list reads as
|
|
385
406
|
// a stride cadence when the fires form a long-enough progression.
|
|
386
407
|
const stride =
|
|
387
|
-
strideFromSegments(ir
|
|
408
|
+
strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts);
|
|
388
409
|
|
|
389
|
-
return (stride ?? listPastThe(segmentWords(ir
|
|
410
|
+
return (stride ?? listPastThe(segmentWords(segmentsOf(ir, 'minute'),
|
|
390
411
|
opts), 'minute', 'hour', opts)) + trailingQualifier(ir, opts);
|
|
391
412
|
}
|
|
392
413
|
|
|
@@ -395,7 +416,7 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
|
|
|
395
416
|
opts: NormalizedOptions): string {
|
|
396
417
|
// A minute-frequency plan is selected only for a stepped minute field,
|
|
397
418
|
// which has segments.
|
|
398
|
-
let phrase = stepCycle60(ir
|
|
419
|
+
let phrase = stepCycle60(stepSegment(ir, 'minute'),
|
|
399
420
|
'minute', 'hour', opts);
|
|
400
421
|
|
|
401
422
|
if (plan.hours.kind === 'during') {
|
|
@@ -410,14 +431,23 @@ function renderMinuteFrequency(ir: IR, plan: PlanOf<'minuteFrequency'>,
|
|
|
410
431
|
hourTimesFromPlan(ir, plan.hours.times, false, opts) + ' hours';
|
|
411
432
|
}
|
|
412
433
|
else if (plan.hours.kind === 'window') {
|
|
413
|
-
|
|
434
|
+
// A minute-frequency cadence ("every 15 minutes") fills the hours from a
|
|
435
|
+
// STEPPED minute, never a wildcard one, so its run is not continuous to the
|
|
436
|
+
// top of the next hour: the default dialect reads "through <last hour>" and
|
|
437
|
+
// every other dialect closes on the step's last fire (`last`).
|
|
438
|
+
phrase += ' ' + rangeWindow({
|
|
439
|
+
continuous: false,
|
|
440
|
+
from: plan.hours.from,
|
|
441
|
+
throughMinute: plan.hours.last,
|
|
442
|
+
to: plan.hours.to
|
|
443
|
+
}, opts);
|
|
414
444
|
}
|
|
415
445
|
else if (plan.hours.kind === 'step') {
|
|
416
446
|
// The plan carries a step only for a clean stride (dividing the day),
|
|
417
447
|
// which confines the cadence to every Nth hour; a stepped hour field's
|
|
418
448
|
// first segment is a step segment.
|
|
419
449
|
phrase += ' ' +
|
|
420
|
-
everyNthHour(ir
|
|
450
|
+
everyNthHour(stepSegment(ir, 'hour'), opts);
|
|
421
451
|
}
|
|
422
452
|
|
|
423
453
|
return phrase + trailingQualifier(ir, opts);
|
|
@@ -460,12 +490,36 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanOf<'minutesAcrossHours'>,
|
|
|
460
490
|
trailingQualifier(ir, opts);
|
|
461
491
|
}
|
|
462
492
|
|
|
463
|
-
|
|
464
|
-
minuteRangeLead(ir.pattern.minute, opts)
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
493
|
+
if (plan.form === 'range') {
|
|
494
|
+
const lead = minuteRangeLead(ir.pattern.minute, opts);
|
|
495
|
+
|
|
496
|
+
if (cadence !== null) {
|
|
497
|
+
return lead + ', ' + cadence + trailingQualifier(ir, opts);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// A plain minute range is a cadence, so an hour list confines it with the
|
|
501
|
+
// "during the … hours" idiom — the same reading the seconds-leading
|
|
502
|
+
// sibling and the wildcard-minute form already use — rather than a
|
|
503
|
+
// clock-time "at <times>" list, which reads as discrete fire points. A
|
|
504
|
+
// lone hour is not a list, so it keeps the "at <time>" frame ("…past the
|
|
505
|
+
// hour, at 9 a.m."), never the plural "hours" confinement.
|
|
506
|
+
if (singleHourFire(plan.times)) {
|
|
507
|
+
return lead + ', at ' +
|
|
508
|
+
hourTimesFromPlan(ir, plan.times, true, opts) +
|
|
509
|
+
trailingQualifier(ir, opts);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return lead + ' during the ' +
|
|
513
|
+
hourTimesFromPlan(ir, plan.times, false, opts) + ' hours' +
|
|
514
|
+
trailingQualifier(ir, opts);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// The 'list' form is a minute list, which has segments; an offset/uneven
|
|
518
|
+
// step enumerated to that list reads as a stride. A list is a set of
|
|
519
|
+
// discrete fire minutes, not a cadence, so it keeps the "at <times>" frame.
|
|
520
|
+
const lead =
|
|
521
|
+
strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
|
|
522
|
+
listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
|
|
469
523
|
'minute', 'hour', opts);
|
|
470
524
|
|
|
471
525
|
if (cadence !== null) {
|
|
@@ -501,7 +555,7 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
|
|
|
501
555
|
plan: PlanOf<'minuteSpanAcrossHourStep'>, opts: NormalizedOptions): string {
|
|
502
556
|
// This plan is reached only under a stepped hour field, whose first
|
|
503
557
|
// segment is a step segment.
|
|
504
|
-
const segment = ir
|
|
558
|
+
const segment = stepSegment(ir, 'hour');
|
|
505
559
|
|
|
506
560
|
// A wildcard minute over a stepped hour is reached only for a clean stride
|
|
507
561
|
// (a bounded or uneven step routes through minutesAcrossHours instead), so it
|
|
@@ -514,8 +568,8 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
|
|
|
514
568
|
// A minute list keeps the same cadence clause; only its lead differs. An
|
|
515
569
|
// offset/uneven step the core enumerated to that list reads as a stride.
|
|
516
570
|
const lead = plan.form === 'list' ?
|
|
517
|
-
strideFromSegments(ir
|
|
518
|
-
listPastThe(segmentWords(ir
|
|
571
|
+
strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
|
|
572
|
+
listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
|
|
519
573
|
'minute', 'hour', opts) :
|
|
520
574
|
minuteRangeLead(ir.pattern.minute, opts);
|
|
521
575
|
// A bounded or uneven hour step reads as its endpoint-pinning cadence after
|
|
@@ -532,7 +586,7 @@ function renderMinuteSpanAcrossHourStep(ir: IR,
|
|
|
532
586
|
function minuteRangeLead(minuteField: string,
|
|
533
587
|
opts: NormalizedOptions): string {
|
|
534
588
|
const bounds = minuteField.split('-');
|
|
535
|
-
const num = seriesNumber(
|
|
589
|
+
const num = seriesNumber();
|
|
536
590
|
|
|
537
591
|
return 'every minute from ' + num(bounds[0]) + through(opts) +
|
|
538
592
|
num(bounds[1]) + ' past the hour';
|
|
@@ -574,8 +628,8 @@ function rangeMinuteLead(ir: IR, opts: NormalizedOptions): string {
|
|
|
574
628
|
|
|
575
629
|
// A non-"0" minute here is a discrete list, which has segments; an
|
|
576
630
|
// offset/uneven step enumerated to that list reads as a stride.
|
|
577
|
-
return strideFromSegments(ir
|
|
578
|
-
opts) ?? listPastThe(segmentWords(ir
|
|
631
|
+
return strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour',
|
|
632
|
+
opts) ?? listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
|
|
579
633
|
'minute', 'hour', opts);
|
|
580
634
|
}
|
|
581
635
|
|
|
@@ -592,7 +646,7 @@ function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
|
|
|
592
646
|
|
|
593
647
|
// An hour-step plan is selected only for a stepped hour field, whose
|
|
594
648
|
// first segment is a step segment.
|
|
595
|
-
return stepHours(ir
|
|
649
|
+
return stepHours(stepSegment(ir, 'hour'), opts) +
|
|
596
650
|
trailingQualifier(ir, opts);
|
|
597
651
|
}
|
|
598
652
|
|
|
@@ -601,21 +655,61 @@ function renderHourStep(ir: IR, plan: PlanOf<'hourStep'>,
|
|
|
601
655
|
// a wildcard minute, which fills every minute and states no separate clause.
|
|
602
656
|
// A pinned/listed/ranged minute is named in its own lead clause, so folding it
|
|
603
657
|
// into the close too would read as a span ("through 5:05 p.m.") that
|
|
604
|
-
// contradicts the minute clause; the window stays bare ("through 5 p.m.").
|
|
658
|
+
// contradicts the minute clause; the window stays bare ("through 5 p.m."). The
|
|
659
|
+
// same wildcard minute is what makes the run CONTINUOUS to the top of the next
|
|
660
|
+
// hour, so it also drives the until-window choice in `rangeWindow`.
|
|
605
661
|
function boundedWindow(plan: PlanOf<'hourRange'>):
|
|
606
|
-
{from: number; to: number;
|
|
607
|
-
const
|
|
662
|
+
{from: number; to: number; closeMinute: number; continuous: boolean} {
|
|
663
|
+
const continuous = plan.minuteForm === 'wildcard';
|
|
664
|
+
const closeMinute = continuous ? plan.boundMinute ?? 0 : 0;
|
|
665
|
+
|
|
666
|
+
return {from: plan.from, closeMinute, to: plan.to, continuous};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// A contiguous hour range as a window phrase. The default English dialect
|
|
670
|
+
// reads a MULTI-hour range whose run is CONTINUOUS to the top of the next hour
|
|
671
|
+
// as an up-to-but-not-including window — "from 9 a.m. until 6 p.m." (the close
|
|
672
|
+
// is the top of the hour after the last, the sense English uses for time
|
|
673
|
+
// windows: 9-17 runs until 6 p.m.); 23 wraps to midnight. The run is continuous
|
|
674
|
+
// only when the minute is wildcard, so every minute of the final hour fires; a
|
|
675
|
+
// restricted minute fires at discrete points (e.g. only `:00`), so the run
|
|
676
|
+
// stops within the final hour and the default dialect reverts to the bare
|
|
677
|
+
// "through <last hour>" span (the minute is named in its own lead clause, so
|
|
678
|
+
// the close stays on the top of the final hour rather than restating a last
|
|
679
|
+
// fire). Every other dialect (and the compact `short` form) always speaks the
|
|
680
|
+
// span, closing on the minute field's last fire within the final hour. A
|
|
681
|
+
// single-hour sub-hour window (`from === to`, e.g. */15 9 firing 9:00 through
|
|
682
|
+
// 9:45) is NOT a multi-hour range: its close is a real fire inside the hour, so
|
|
683
|
+
// it always keeps "through" — naming "until 10 a.m." would overstate the span
|
|
684
|
+
// past the last fire.
|
|
685
|
+
function rangeWindow(window: HourWindowSpec,
|
|
686
|
+
opts: NormalizedOptions): string {
|
|
687
|
+
const {from, to, throughMinute, continuous} = window;
|
|
688
|
+
const open = 'from ' + getTime({hour: from, minute: 0}, opts);
|
|
608
689
|
|
|
609
|
-
|
|
690
|
+
if (opts.style.untilWindow && !opts.short && from !== to) {
|
|
691
|
+
return continuous ?
|
|
692
|
+
open + ' until ' + getTime({hour: (to + 1) % 24, minute: 0}, opts) :
|
|
693
|
+
open + through(opts) + getTime({hour: to, minute: 0}, opts);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return open + through(opts) +
|
|
697
|
+
getTime({hour: to, minute: throughMinute}, opts);
|
|
610
698
|
}
|
|
611
699
|
|
|
612
|
-
// An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m.".
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
|
|
700
|
+
// An hour window phrase, e.g. "from 9 a.m. through 5:45 p.m." (or "from 9 a.m.
|
|
701
|
+
// until 6 p.m." in the default dialect, when the minute is wildcard). Windows
|
|
702
|
+
// open at the top of the first hour and close at the minute field's last fire
|
|
703
|
+
// within the final hour.
|
|
704
|
+
function hourWindow(
|
|
705
|
+
window: {from: number; to: number; closeMinute: number; continuous: boolean},
|
|
616
706
|
opts: NormalizedOptions): string {
|
|
617
|
-
return
|
|
618
|
-
|
|
707
|
+
return rangeWindow({
|
|
708
|
+
continuous: window.continuous,
|
|
709
|
+
from: window.from,
|
|
710
|
+
throughMinute: window.closeMinute,
|
|
711
|
+
to: window.to
|
|
712
|
+
}, opts);
|
|
619
713
|
}
|
|
620
714
|
|
|
621
715
|
// Expand a discrete set of hours and minutes into clock times prefixed by
|
|
@@ -645,7 +739,15 @@ function renderClockTimes(ir: IR, plan: PlanOf<'clockTimes'>,
|
|
|
645
739
|
}, opts);
|
|
646
740
|
});
|
|
647
741
|
|
|
648
|
-
return interpretDayQualifier(ir, opts) + 'at ' + joinList(times, opts)
|
|
742
|
+
return interpretDayQualifier(ir, opts) + 'at ' + joinList(times, opts) +
|
|
743
|
+
dayUnionTrail(ir, opts);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// The trailing day-union condition for a clock-time form (which leads with its
|
|
747
|
+
// time, not a day qualifier), or an empty string when the pattern is not a day
|
|
748
|
+
// union. The cadence renderers carry this through `trailingQualifier` instead.
|
|
749
|
+
function dayUnionTrail(ir: IR, opts: NormalizedOptions): string {
|
|
750
|
+
return isDayUnion(ir, opts) ? dayUnionCondition(ir, opts) : '';
|
|
649
751
|
}
|
|
650
752
|
|
|
651
753
|
// Compact form for a clock-time set past the enumeration cap. A single
|
|
@@ -666,7 +768,7 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
|
666
768
|
|
|
667
769
|
// A compact clock-time plan is reached only for discrete hours, which
|
|
668
770
|
// have segments.
|
|
669
|
-
const hasRange = ir.
|
|
771
|
+
const hasRange = segmentsOf(ir, 'hour').some(function range(segment) {
|
|
670
772
|
return segment.kind === 'range';
|
|
671
773
|
});
|
|
672
774
|
|
|
@@ -679,14 +781,14 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
|
679
781
|
const fold = {minute: plan.minute, second: ir.analyses.clockSecond};
|
|
680
782
|
|
|
681
783
|
return interpretDayQualifier(ir, opts) + 'at ' +
|
|
682
|
-
hourSegmentTimes(ir, fold, true, opts);
|
|
784
|
+
hourSegmentTimes(ir, fold, true, opts) + dayUnionTrail(ir, opts);
|
|
683
785
|
}
|
|
684
786
|
|
|
685
787
|
const minuteLead =
|
|
686
788
|
// The non-fold branch is a minute list, which has segments. An
|
|
687
789
|
// offset/uneven step enumerated to that list reads as a stride.
|
|
688
|
-
strideFromSegments(ir
|
|
689
|
-
listPastThe(segmentWords(ir
|
|
790
|
+
strideFromSegments(segmentsOf(ir, 'minute'), 'minute', 'hour', opts) ??
|
|
791
|
+
listPastThe(segmentWords(segmentsOf(ir, 'minute'), opts),
|
|
690
792
|
'minute', 'hour', opts);
|
|
691
793
|
// A uneven hour stride reads as a cadence after the minute lead, not a wall
|
|
692
794
|
// of clock-time columns.
|
|
@@ -706,39 +808,330 @@ function renderCompactClockTimes(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
|
706
808
|
|
|
707
809
|
// A folded hour field that includes a contiguous range reads with the
|
|
708
810
|
// hour-range frame: a shared minute lead ("every hour" / "at 30 minutes
|
|
709
|
-
// past the hour"), each range as a
|
|
710
|
-
//
|
|
811
|
+
// past the hour"), each range as a window, and any non-contiguous hour
|
|
812
|
+
// appended by `outlierTail` ("and at Z").
|
|
711
813
|
function foldedHourWindows(ir: IR, plan: PlanOf<'compactClockTimes'>,
|
|
712
814
|
opts: NormalizedOptions): string {
|
|
713
815
|
const minute = plan.minute;
|
|
714
816
|
const windows: string[] = [];
|
|
715
|
-
const
|
|
817
|
+
const times = collectHourOutliers(ir).map(function time(hour) {
|
|
818
|
+
return getTime({hour, minute}, opts);
|
|
819
|
+
});
|
|
716
820
|
|
|
717
|
-
// Reached only via the fold branch under discrete hours, which have
|
|
718
|
-
//
|
|
719
|
-
|
|
821
|
+
// Reached only via the fold branch under discrete hours, which have segments.
|
|
822
|
+
// A folded minute is a discrete pin/list, never a wildcard, so the run is not
|
|
823
|
+
// continuous to the top of the next hour: the window is not an until-window.
|
|
824
|
+
segmentsOf(ir, 'hour').forEach(function classify(segment) {
|
|
720
825
|
if (segment.kind === 'range') {
|
|
721
|
-
windows.push(
|
|
722
|
-
|
|
723
|
-
|
|
826
|
+
windows.push(rangeWindow({
|
|
827
|
+
continuous: false,
|
|
828
|
+
from: +segment.bounds[0],
|
|
829
|
+
throughMinute: minute,
|
|
830
|
+
to: +segment.bounds[1]
|
|
831
|
+
}, opts));
|
|
724
832
|
}
|
|
725
|
-
|
|
726
|
-
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
const phrase = rangeMinuteLead(ir, opts) + ' ' + joinList(windows, opts);
|
|
836
|
+
|
|
837
|
+
return phrase + outlierTail(times, opts);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// The hours outside a contiguous run — every non-range segment's values, with
|
|
841
|
+
// a step contributing its whole fire set.
|
|
842
|
+
function collectHourOutliers(ir: IR): number[] {
|
|
843
|
+
const hours: number[] = [];
|
|
844
|
+
|
|
845
|
+
// Reached only under discrete hours, which carry segments.
|
|
846
|
+
segmentsOf(ir, 'hour').forEach(function classify(segment) {
|
|
847
|
+
if (segment.kind === 'step') {
|
|
848
|
+
hours.push(...segment.fires);
|
|
727
849
|
}
|
|
728
|
-
else {
|
|
729
|
-
|
|
850
|
+
else if (segment.kind !== 'range') {
|
|
851
|
+
hours.push(+segment.value);
|
|
730
852
|
}
|
|
731
853
|
});
|
|
732
854
|
|
|
733
|
-
|
|
855
|
+
return hours;
|
|
856
|
+
}
|
|
734
857
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
858
|
+
// Join the outlier hour times that follow a contiguous-run window — the hours
|
|
859
|
+
// outside the run, enumerated as "and at 10 p.m.". (A fold always carries a
|
|
860
|
+
// restricted minute, so its run reads the "through" span, never the
|
|
861
|
+
// until-window; the additive "plus" idiom that paired with the until-window no
|
|
862
|
+
// longer applies here.)
|
|
863
|
+
function outlierTail(times: string[], opts: NormalizedOptions): string {
|
|
864
|
+
if (!times.length) {
|
|
865
|
+
return '';
|
|
739
866
|
}
|
|
740
867
|
|
|
741
|
-
return
|
|
868
|
+
return ' and at ' + joinList(times, opts);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// --- Confinement frame. ---
|
|
872
|
+
//
|
|
873
|
+
// Under a finer LEADING CADENCE — the finest restricted field spoken as a
|
|
874
|
+
// recurrence ("every second", "every 15 seconds", "every minute", "every two
|
|
875
|
+
// minutes") — each COARSER restricted field reads as a confinement, not a
|
|
876
|
+
// juxtaposed cadence: "every second during minute :00 of every hour", "every
|
|
877
|
+
// second of the midnight hour", "every two minutes from midnight until 1 a.m.".
|
|
878
|
+
// A redundant unrestricted finer field drops ("every second" already spans all
|
|
879
|
+
// minutes, so a wildcard minute is not stated). The leading field is the
|
|
880
|
+
// seconds when it is a wildcard or clean step; otherwise the minute, when the
|
|
881
|
+
// second is a plain :00 and the minute is a wildcard or clean step. A single,
|
|
882
|
+
// range, or list lead is a clock-point form ("at 30 seconds past the minute"),
|
|
883
|
+
// not a cadence, and is left to the existing renderers.
|
|
884
|
+
|
|
885
|
+
// Whether a field token is a wildcard or a clean step (`*/n`) — the two shapes
|
|
886
|
+
// that read as a leading cadence. A bounded step (`a-b/n`) is a windowed set,
|
|
887
|
+
// not a clean day/hour-spanning cadence.
|
|
888
|
+
function isCadenceField(token: string): boolean {
|
|
889
|
+
return token === '*' ||
|
|
890
|
+
token.startsWith('*/') && token.indexOf('-') === -1;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// The leading cadence and whether the second is the leading field, or null when
|
|
894
|
+
// the pattern has no cadence lead (the finest restricted field is a clock-point
|
|
895
|
+
// single/range/list). The seconds lead when restricted as a cadence; otherwise
|
|
896
|
+
// the minute leads when the second is a plain :00 and the minute is a cadence.
|
|
897
|
+
function leadingCadence(ir: IR, opts: NormalizedOptions):
|
|
898
|
+
{text: string; secondLead: boolean} | null {
|
|
899
|
+
const {second, minute} = ir.pattern;
|
|
900
|
+
|
|
901
|
+
if (isCadenceField(second)) {
|
|
902
|
+
return {secondLead: true, text: secondsClause(ir, 'minute', opts)};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (second === '0' && isCadenceField(minute)) {
|
|
906
|
+
const text = minute === '*' ?
|
|
907
|
+
'every minute' :
|
|
908
|
+
// A clean minute step's first segment is a step segment.
|
|
909
|
+
stepCycle60(stepSegment(ir, 'minute'),
|
|
910
|
+
'minute', 'hour', opts);
|
|
911
|
+
|
|
912
|
+
return {secondLead: false, text};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// A pinned minute (single/range/list) under a seconds lead reads as a
|
|
919
|
+
// confinement: "during minute :NN", "during minutes :NN through :MM", "during
|
|
920
|
+
// minutes :NN and :MM". A clean minute step reads "of every other minute". A
|
|
921
|
+
// wildcard minute is redundant under the seconds cadence and drops (empty).
|
|
922
|
+
function minuteConfinement(ir: IR, opts: NormalizedOptions): string {
|
|
923
|
+
const minute = ir.pattern.minute;
|
|
924
|
+
|
|
925
|
+
if (minute === '*') {
|
|
926
|
+
return '';
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (isCadenceField(minute)) {
|
|
930
|
+
// The gate admits only the `*/2` "every other minute" step here; other
|
|
931
|
+
// minute steps defer to the existing renderer.
|
|
932
|
+
return ' of every other minute';
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// A minute single/range/list under the seconds lead. The minute reads as a
|
|
936
|
+
// ":NN" clock-minute confinement, never "N minutes past the hour" (that is
|
|
937
|
+
// the minute-lead clock-point form).
|
|
938
|
+
const segments = segmentsOf(ir, 'minute');
|
|
939
|
+
|
|
940
|
+
if (ir.shapes.minute === 'single') {
|
|
941
|
+
return ' during minute :' + pad(minute);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (ir.shapes.minute === 'range') {
|
|
945
|
+
const bounds = minute.split('-');
|
|
946
|
+
|
|
947
|
+
return ' during minutes :' + pad(bounds[0]) + through(opts) + ':' +
|
|
948
|
+
pad(bounds[1]);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const values = segmentWords(segments, opts).map(function colon(word) {
|
|
952
|
+
return ':' + pad(word);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
return ' during minutes ' + joinList(values, opts);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// A restricted hour under a finer cadence reads as a confinement. The form
|
|
959
|
+
// depends on the nearest stated finer field: a stepped minute makes a single
|
|
960
|
+
// hour a span ("from midnight until 1 a.m."); a pinned minute makes it a clock
|
|
961
|
+
// point ("at midnight"); a wildcard/absent minute makes it the hour itself
|
|
962
|
+
// ("of the midnight hour"). A clean hour step is "of every other hour"; a range
|
|
963
|
+
// reuses the until-window; a list or stepped range reads "during the … hours".
|
|
964
|
+
// A wildcard hour drops (empty).
|
|
965
|
+
function hourConfinement(ir: IR, opts: NormalizedOptions): string {
|
|
966
|
+
const hour = ir.pattern.hour;
|
|
967
|
+
|
|
968
|
+
if (hour === '*') {
|
|
969
|
+
// A pinned minute confinement ("during minute :00") repeats across every
|
|
970
|
+
// hour, so the hour is named as the unit of recurrence; a stepped minute
|
|
971
|
+
// ("of every other minute") or absent minute already implies all hours.
|
|
972
|
+
const minutePinned = ir.pattern.minute !== '*' &&
|
|
973
|
+
!isCadenceField(ir.pattern.minute);
|
|
974
|
+
|
|
975
|
+
return minutePinned ? ' of every hour' : '';
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (isCadenceField(hour)) {
|
|
979
|
+
return hour === '*/2' ? ' of every other hour' : '';
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (ir.shapes.hour === 'single') {
|
|
983
|
+
const h = +hour;
|
|
984
|
+
|
|
985
|
+
if (ir.shapes.minute === 'step') {
|
|
986
|
+
return ' from ' + getTime({hour: h, minute: 0}, opts) + ' until ' +
|
|
987
|
+
getTime({hour: (h + 1) % 24, minute: 0}, opts);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// A pinned minute confinement already named the minute, so the hour reads
|
|
991
|
+
// as a plain clock point; a wildcard or absent minute makes the hour the
|
|
992
|
+
// unit of recurrence ("of the midnight hour").
|
|
993
|
+
if (ir.pattern.minute !== '*' && !isCadenceField(ir.pattern.minute)) {
|
|
994
|
+
return ' at ' + getTime({hour: h, minute: 0}, opts);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return ' of the ' + getTime({hour: h, minute: 0}, opts) + ' hour';
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
if (ir.shapes.hour === 'range') {
|
|
1001
|
+
const bounds = hour.split('-');
|
|
1002
|
+
|
|
1003
|
+
// The until-window holds only when the run is continuous to the top of the
|
|
1004
|
+
// next hour — a wildcard minute fills every minute of the final hour; a
|
|
1005
|
+
// confined minute (":00", a step) stops within it, reading "through".
|
|
1006
|
+
return ' ' + rangeWindow({
|
|
1007
|
+
continuous: ir.pattern.minute === '*',
|
|
1008
|
+
from: +bounds[0],
|
|
1009
|
+
throughMinute: 0,
|
|
1010
|
+
to: +bounds[1]
|
|
1011
|
+
}, opts);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// An hour list or stepped range reads "during the <times> hours".
|
|
1015
|
+
return ' during the ' +
|
|
1016
|
+
hourSegmentTimes(ir, {minute: 0, second: null}, false, opts) + ' hours';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Whether the hour field reads as a contiguous window — a real range whose
|
|
1020
|
+
// close depends on the finer field's last fire. A finer STEP cadence does not
|
|
1021
|
+
// fill the closing hour ("from 9 a.m. until 5:45 p.m."), so that window is left
|
|
1022
|
+
// to the existing windowing renderer rather than the confinement frame, which
|
|
1023
|
+
// closes on the top of the next hour ("until 6 p.m.").
|
|
1024
|
+
function isContiguousHourRange(ir: IR): boolean {
|
|
1025
|
+
return ir.shapes.hour === 'range';
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Whether an hour field is confinement-eligible. An OPEN hour stride — a clean
|
|
1029
|
+
// `*/n`, an offset `m/n`, or a uneven step — reads as a cadence ("every three
|
|
1030
|
+
// hours from 2 a.m."), and only the `*/2` form has a dedicated confinement
|
|
1031
|
+
// idiom ("of every other hour"), so other open steps defer. A BOUNDED stepped
|
|
1032
|
+
// range (`a-b/n`, e.g. `9-17/2`) is a discrete set of named hours the
|
|
1033
|
+
// confinement frame speaks as a list ("during the 9 a.m., 11 a.m., … hours").
|
|
1034
|
+
function confinableHour(ir: IR): boolean {
|
|
1035
|
+
if (ir.shapes.hour !== 'step') {
|
|
1036
|
+
return true;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Reached only under a stepped hour, whose first segment is a step segment.
|
|
1040
|
+
const segment = stepSegment(ir, 'hour');
|
|
1041
|
+
|
|
1042
|
+
return ir.pattern.hour === '*/2' || segment.startToken.indexOf('-') !== -1;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Whether a minute list is really a stride the existing renderer speaks as a
|
|
1046
|
+
// cadence ("every two minutes from 3 through 59"): such a progression is not a
|
|
1047
|
+
// short explicit ":NN" confinement, so it defers.
|
|
1048
|
+
function isMinuteStride(ir: IR): boolean {
|
|
1049
|
+
if (ir.shapes.minute !== 'list') {
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const values = singleValues(segmentsOf(ir, 'minute'));
|
|
1054
|
+
|
|
1055
|
+
return values !== null && arithmeticStep(values) !== null;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Whether the pattern is in the confinement frame's supported shape-set. The
|
|
1059
|
+
// frame covers a finer leading cadence (seconds, or minute under a :00 second)
|
|
1060
|
+
// with each coarser field as a confinement; shapes outside it defer to the
|
|
1061
|
+
// existing renderers, which already produce that phrasing for them.
|
|
1062
|
+
function confinementEligible(ir: IR,
|
|
1063
|
+
lead: {secondLead: boolean}): boolean {
|
|
1064
|
+
const {minute, hour} = ir.pattern;
|
|
1065
|
+
const minuteStep = isCadenceField(minute) && minute !== '*';
|
|
1066
|
+
|
|
1067
|
+
// A non-`*/2` hour stride keeps the existing cadence form.
|
|
1068
|
+
if (!confinableHour(ir)) {
|
|
1069
|
+
return false;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (lead.secondLead) {
|
|
1073
|
+
// A minute STEP is supported only as the `*/2` "every other minute" idiom,
|
|
1074
|
+
// and only where it fills the coarser field: a contiguous hour range or a
|
|
1075
|
+
// single hour both close on the minute's real last fire, which the
|
|
1076
|
+
// windowing renderer already speaks. The `*/2` step fills both, so it keeps
|
|
1077
|
+
// the "of every other minute" confinement; other steps defer entirely.
|
|
1078
|
+
if (minuteStep) {
|
|
1079
|
+
return minute === '*/2' && !isContiguousHourRange(ir);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// A minute list that is really a stride keeps its cadence form; a short
|
|
1083
|
+
// explicit minute list crossed with a discrete hour LIST is a wall of
|
|
1084
|
+
// distinct clock times ("9:00 a.m., 9:25 a.m., …"), not a single minute
|
|
1085
|
+
// confinement. Both stay with the enumerating renderer.
|
|
1086
|
+
if (isMinuteStride(ir) ||
|
|
1087
|
+
ir.shapes.minute === 'list' && ir.shapes.hour === 'list') {
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return true;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// A minute-LEAD cadence (second :00). The existing renderers already produce
|
|
1095
|
+
// that phrasing for a single/range/list hour and for a non-`*/2` hour
|
|
1096
|
+
// step; the confinement frame only changes the `*/2` hour ("of every other
|
|
1097
|
+
// hour") and the single hour under an "every other minute" step ("from
|
|
1098
|
+
// midnight until 1 a.m."). Everything else defers.
|
|
1099
|
+
if (hour === '*/2') {
|
|
1100
|
+
return true;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return ir.shapes.hour === 'single' && minute === '*/2';
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Render the pattern with the confinement frame: a finer leading cadence with
|
|
1107
|
+
// each coarser field as a confinement, or null when it does not apply. Routed
|
|
1108
|
+
// to from the cadence renderers in place of the older juxtaposed-cadence and
|
|
1109
|
+
// duration-frame forms.
|
|
1110
|
+
function confinement(ir: IR, opts: NormalizedOptions): string | null {
|
|
1111
|
+
// The confinement frame is scoped to the default (US) dialect, the one that
|
|
1112
|
+
// carries the until-window; every other dialect and the compact `short` form
|
|
1113
|
+
// keep their established juxtaposed-cadence / duration-frame phrasing.
|
|
1114
|
+
if (!opts.style.untilWindow || opts.short) {
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// With nothing coarser to confine (minute and hour both wildcard), the bare
|
|
1119
|
+
// cadence renderers already speak the pattern ("every second", "every
|
|
1120
|
+
// minute"); the confinement frame only applies once a coarser field is set.
|
|
1121
|
+
if (ir.pattern.minute === '*' && ir.pattern.hour === '*') {
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const lead = leadingCadence(ir, opts);
|
|
1126
|
+
|
|
1127
|
+
if (!lead || !confinementEligible(ir, lead)) {
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const minutePart = lead.secondLead ? minuteConfinement(ir, opts) : '';
|
|
1132
|
+
|
|
1133
|
+
return lead.text + minutePart + hourConfinement(ir, opts) +
|
|
1134
|
+
trailingQualifier(ir, opts);
|
|
742
1135
|
}
|
|
743
1136
|
|
|
744
1137
|
// The plan dispatch table.
|
|
@@ -790,31 +1183,14 @@ function renderStride(stride: Stride, opts: NormalizedOptions): string {
|
|
|
790
1183
|
pluralize(start, unit) + ' past the ' + anchor;
|
|
791
1184
|
}
|
|
792
1185
|
|
|
793
|
-
// A bounded, non-wrapping set: pin both endpoints.
|
|
794
|
-
//
|
|
795
|
-
|
|
796
|
-
const num = seriesNumber([start, last], opts);
|
|
1186
|
+
// A bounded, non-wrapping set: pin both endpoints. Each bound is a value, so
|
|
1187
|
+
// it reads as a digit, matching the range idiom ("from 0 through 30").
|
|
1188
|
+
const num = seriesNumber();
|
|
797
1189
|
|
|
798
1190
|
return cadence + ' from ' + num(start) + through(opts) + num(last) + ' ' +
|
|
799
1191
|
pluralize(last, unit) + ' past the ' + anchor;
|
|
800
1192
|
}
|
|
801
1193
|
|
|
802
|
-
// The sorted numeric values a field's segments cover, or null if any segment
|
|
803
|
-
// is not a discrete single (a range or sub-step is not a plain fire list).
|
|
804
|
-
function singleValues(segments: Segment[]): number[] | null {
|
|
805
|
-
const values: number[] = [];
|
|
806
|
-
|
|
807
|
-
for (const segment of segments) {
|
|
808
|
-
if (segment.kind !== 'single') {
|
|
809
|
-
return null;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
values.push(+segment.value);
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
return values;
|
|
816
|
-
}
|
|
817
|
-
|
|
818
1194
|
// Speak a minute/second field's enumerated fires as a step cadence when they
|
|
819
1195
|
// form an arithmetic progression long enough to beat the list (the core
|
|
820
1196
|
// enumerates an offset/uneven step to this fire list; the IR is unchanged, so
|
|
@@ -916,17 +1292,6 @@ function hourStrideCadence(stride: {start: number; interval: number;
|
|
|
916
1292
|
through(opts) + getTime({hour: last, minute: 0}, opts);
|
|
917
1293
|
}
|
|
918
1294
|
|
|
919
|
-
// Whether an hour stride wraps the day cleanly from within its first interval
|
|
920
|
-
// (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
|
|
921
|
-
// stride has no distinct endpoint and keeps its bare or "from M" cadence. Every
|
|
922
|
-
// other stride — a uneven interval, or one starting at or past its interval
|
|
923
|
-
// (a bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
|
|
924
|
-
function offsetCleanStride(
|
|
925
|
-
stride: {start: number; interval: number}
|
|
926
|
-
): boolean {
|
|
927
|
-
return stride.start < stride.interval && 24 % stride.interval === 0;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
1295
|
// The bounded cadence for an hour stride that pins both clock-time endpoints,
|
|
931
1296
|
// or null when the hour is not such a stride. The core rewrites a uneven step
|
|
932
1297
|
// to its fire list, so a minute window/list/step crossed with it lands in the
|
|
@@ -944,40 +1309,6 @@ function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
|
|
|
944
1309
|
return hourStrideCadence(stride, opts);
|
|
945
1310
|
}
|
|
946
1311
|
|
|
947
|
-
// An hour list's arithmetic progression, or null when its values are not a
|
|
948
|
-
// step the renderer should speak as a cadence. The core rewrites a uneven hour
|
|
949
|
-
// step (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its
|
|
950
|
-
// literal fire list, indistinguishable in the IR from a hand-written list; the
|
|
951
|
-
// renderer recovers the cadence from the values. A progression starting at
|
|
952
|
-
// zero is a `*/n` step however short (0,7,14,21 is `*/7`); a non-zero one is
|
|
953
|
-
// only a step when it is too long to be a deliberate clock-time list (e.g.
|
|
954
|
-
// 9,17 is two named times, not a cadence), the same length the minute/second
|
|
955
|
-
// list path uses. Interval one is a plain range, never a step.
|
|
956
|
-
function hourListStride(values: number[]):
|
|
957
|
-
{start: number; interval: number; last: number} | null {
|
|
958
|
-
if (values.length < 2) {
|
|
959
|
-
return null;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
const interval = values[1] - values[0];
|
|
963
|
-
|
|
964
|
-
if (interval < 2) {
|
|
965
|
-
return null;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
for (let i = 2; i < values.length; i += 1) {
|
|
969
|
-
if (values[i] - values[i - 1] !== interval) {
|
|
970
|
-
return null;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
if (values[0] !== 0 && values.length < 5) {
|
|
975
|
-
return null;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
return {interval, last: values[values.length - 1], start: values[0]};
|
|
979
|
-
}
|
|
980
|
-
|
|
981
1312
|
// The hour field's stride, or null when the hour is not a cadence: a step
|
|
982
1313
|
// segment yields its {start, interval, last} directly; an all-single hour
|
|
983
1314
|
// list yields one only when its values form a step progression (so an irregular
|
|
@@ -988,7 +1319,7 @@ function hourStride(ir: IR):
|
|
|
988
1319
|
{start: number; interval: number; last: number} | null {
|
|
989
1320
|
// Reached only from the clock-time paths, which run under discrete hours
|
|
990
1321
|
// and so always carry hour segments.
|
|
991
|
-
const segments = ir
|
|
1322
|
+
const segments = segmentsOf(ir, 'hour');
|
|
992
1323
|
|
|
993
1324
|
if (segments.length === 1 && segments[0].kind === 'step') {
|
|
994
1325
|
const segment = segments[0];
|
|
@@ -1083,12 +1414,12 @@ function hourCadence(ir: IR, minute: number,
|
|
|
1083
1414
|
// minute during every other hour", matching the "every minute during every
|
|
1084
1415
|
// other hour" idiom and keeping it distinct from the bare hour-step form
|
|
1085
1416
|
// ("every two hours") so the minute-0 confinement is never heard as it.
|
|
1086
|
-
const
|
|
1417
|
+
const minuteZeroStride = minute === 0 && subMinuteSecond(ir) &&
|
|
1087
1418
|
cleanStrideSegment(ir);
|
|
1088
1419
|
|
|
1089
|
-
if (
|
|
1420
|
+
if (minuteZeroStride) {
|
|
1090
1421
|
return secondsClause(ir, 'minute', opts) + ' for one minute ' +
|
|
1091
|
-
everyNthHour(
|
|
1422
|
+
everyNthHour(minuteZeroStride, opts) + trailingQualifier(ir, opts);
|
|
1092
1423
|
}
|
|
1093
1424
|
|
|
1094
1425
|
// A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
|
|
@@ -1109,7 +1440,7 @@ function hourCadence(ir: IR, minute: number,
|
|
|
1109
1440
|
// or an arithmetic-progression list, which keep the bounded cadence form).
|
|
1110
1441
|
function cleanStrideSegment(ir: IR): StepSegment | null {
|
|
1111
1442
|
// Reached only after hourStride confirmed a stride, so hour segments exist.
|
|
1112
|
-
const segments = ir
|
|
1443
|
+
const segments = segmentsOf(ir, 'hour');
|
|
1113
1444
|
const segment = segments.length === 1 && segments[0];
|
|
1114
1445
|
|
|
1115
1446
|
if (!segment || segment.kind !== 'step' ||
|
|
@@ -1129,44 +1460,40 @@ function cleanStrideSegment(ir: IR): StepSegment | null {
|
|
|
1129
1460
|
function hasHourWindow(ir: IR): boolean {
|
|
1130
1461
|
// Reached only from the clock-time paths, which run under discrete hours
|
|
1131
1462
|
// and so always carry hour segments.
|
|
1132
|
-
return ir.
|
|
1463
|
+
return segmentsOf(ir, 'hour').some(function range(segment) {
|
|
1133
1464
|
return segment.kind === 'range';
|
|
1134
1465
|
});
|
|
1135
1466
|
}
|
|
1136
1467
|
|
|
1137
1468
|
// The hour-range window as a cadence tail at the top of each hour: each range
|
|
1138
|
-
// segment is a
|
|
1139
|
-
//
|
|
1140
|
-
// The minute has already folded into the
|
|
1141
|
-
//
|
|
1469
|
+
// segment is a window ("every hour from 9 a.m. through 8 p.m."), and any
|
|
1470
|
+
// non-contiguous single hour is appended by `outlierTail` ("and at 10 p.m.").
|
|
1471
|
+
// The minute has already folded into the "every hour" lead — a single pinned
|
|
1472
|
+
// minute, never a wildcard — so the run is not continuous to the top of the
|
|
1473
|
+
// next hour and the window keeps "through". Mirrors foldedHourWindows but
|
|
1474
|
+
// pinned to minute 0.
|
|
1142
1475
|
function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
|
|
1143
1476
|
const windows: string[] = [];
|
|
1144
|
-
const
|
|
1477
|
+
const outlierHours = collectHourOutliers(ir);
|
|
1145
1478
|
|
|
1146
1479
|
// Reached only after hasHourWindow, so hour segments exist.
|
|
1147
|
-
ir.
|
|
1480
|
+
segmentsOf(ir, 'hour').forEach(function classify(segment) {
|
|
1148
1481
|
if (segment.kind === 'range') {
|
|
1149
|
-
windows.push(
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
}
|
|
1156
|
-
else {
|
|
1157
|
-
singles.push(+segment.value);
|
|
1482
|
+
windows.push(rangeWindow({
|
|
1483
|
+
continuous: false,
|
|
1484
|
+
from: +segment.bounds[0],
|
|
1485
|
+
throughMinute: 0,
|
|
1486
|
+
to: +segment.bounds[1]
|
|
1487
|
+
}, opts));
|
|
1158
1488
|
}
|
|
1159
1489
|
});
|
|
1160
1490
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
return getTime({hour, minute: 0}, opts);
|
|
1166
|
-
}), opts);
|
|
1167
|
-
}
|
|
1491
|
+
const phrase = 'every hour ' + joinList(windows, opts);
|
|
1492
|
+
const times = outlierHours.map(function time(hour) {
|
|
1493
|
+
return getTime({hour, minute: 0}, opts);
|
|
1494
|
+
});
|
|
1168
1495
|
|
|
1169
|
-
return phrase;
|
|
1496
|
+
return phrase + outlierTail(times, opts);
|
|
1170
1497
|
}
|
|
1171
1498
|
|
|
1172
1499
|
// Render an hour range (or a list whose segments include a range) under a
|
|
@@ -1211,41 +1538,55 @@ function hourRangeCadence(ir: IR, minute: number,
|
|
|
1211
1538
|
|
|
1212
1539
|
// --- List and segment phrasing. ---
|
|
1213
1540
|
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
const anyBig = values.some(function big(v) {
|
|
1221
|
-
return +v > 10;
|
|
1222
|
-
});
|
|
1223
|
-
|
|
1541
|
+
// Number style for the bounds of a "from X through Y" series — a range or a
|
|
1542
|
+
// pinned-endpoint stride. The boundary of a range is a clock/calendar VALUE,
|
|
1543
|
+
// not a frequency, so it always reads as a digit ("from 0 through 10", "from 1
|
|
1544
|
+
// through 5"), matching the minutes-/seconds-past convention; only the "every
|
|
1545
|
+
// N" multiplier keeps the spell-when-small style.
|
|
1546
|
+
function seriesNumber(): (n: number | string) => string | number {
|
|
1224
1547
|
return function format(n) {
|
|
1225
|
-
return
|
|
1548
|
+
return '' + n;
|
|
1226
1549
|
};
|
|
1227
1550
|
}
|
|
1228
1551
|
|
|
1229
|
-
//
|
|
1552
|
+
// The number style for an enumerated set of values: a genuine LIST (two or
|
|
1553
|
+
// more comma-separated values) reads as numerals throughout ("at 4, 6, and 9
|
|
1554
|
+
// minutes past the hour") even when every value is small; a lone value keeps
|
|
1555
|
+
// the dialect's spelled-when-small style ("at five minutes past the hour"),
|
|
1556
|
+
// matching the single-value renderers. The list comma is the cue that pushes
|
|
1557
|
+
// the eye to numerals.
|
|
1558
|
+
function listNumber(count: number, opts: NormalizedOptions):
|
|
1559
|
+
(n: number | string) => string | number {
|
|
1560
|
+
return count > 1 ?
|
|
1561
|
+
function asNumeral(n) {
|
|
1562
|
+
return '' + n;
|
|
1563
|
+
} :
|
|
1564
|
+
function spelled(n) {
|
|
1565
|
+
return getNumber(n, opts);
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// Render numeric fire values for an enumerated list: a multi-value list reads
|
|
1570
|
+
// as numerals, a lone value stays spelled (see `listNumber`).
|
|
1230
1571
|
function numberWords(fires: number[],
|
|
1231
1572
|
opts: NormalizedOptions): (string | number)[] {
|
|
1232
|
-
return fires.map(
|
|
1573
|
+
return fires.map(listNumber(fires.length, opts));
|
|
1233
1574
|
}
|
|
1234
1575
|
|
|
1235
|
-
// Render classified segments as words
|
|
1236
|
-
// "<a> through <b>" pairs, step segments as their
|
|
1237
|
-
//
|
|
1576
|
+
// Render classified segments as words for an enumerated list: singles as
|
|
1577
|
+
// numbers, ranges as "<a> through <b>" pairs, step segments as their
|
|
1578
|
+
// enumerated fires. A multi-value list numeralizes throughout; a lone value
|
|
1579
|
+
// keeps the spelled-when-small style (see `listNumber`).
|
|
1238
1580
|
function segmentWords(segments: Segment[],
|
|
1239
1581
|
opts: NormalizedOptions): (string | number)[] {
|
|
1240
|
-
const
|
|
1241
|
-
(string | number)[] {
|
|
1582
|
+
const count = segments.reduce(function tally(sum, segment) {
|
|
1242
1583
|
if (segment.kind === 'range') {
|
|
1243
|
-
return
|
|
1584
|
+
return sum + 1;
|
|
1244
1585
|
}
|
|
1245
1586
|
|
|
1246
|
-
return segment.kind === 'step' ? segment.fires :
|
|
1247
|
-
});
|
|
1248
|
-
const num =
|
|
1587
|
+
return sum + (segment.kind === 'step' ? segment.fires.length : 1);
|
|
1588
|
+
}, 0);
|
|
1589
|
+
const num = listNumber(count, opts);
|
|
1249
1590
|
|
|
1250
1591
|
return segments.flatMap(function word(segment) {
|
|
1251
1592
|
if (segment.kind === 'range') {
|
|
@@ -1298,6 +1639,13 @@ function hourTimes(hours: number[], opts: NormalizedOptions): string {
|
|
|
1298
1639
|
return joinList(times, opts);
|
|
1299
1640
|
}
|
|
1300
1641
|
|
|
1642
|
+
// Whether an hour-times plan names exactly one hour. A lone hour is not a
|
|
1643
|
+
// list, so the cadence renderers keep the "at <time>" frame rather than the
|
|
1644
|
+
// plural "during the … hours" confinement.
|
|
1645
|
+
function singleHourFire(times: HourTimesPlan): boolean {
|
|
1646
|
+
return times.kind === 'fires' && times.fires.length === 1;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1301
1649
|
// The hour times accompanying a window phrase: enumerated fires up to the
|
|
1302
1650
|
// cap, segment rendering past it (decided by the core). `atContext` marks
|
|
1303
1651
|
// an "at <times>" frame (vs "during the <times> hours").
|
|
@@ -1329,7 +1677,7 @@ function hourSegmentTimes(ir: IR,
|
|
|
1329
1677
|
const {minute, second} = fold;
|
|
1330
1678
|
// Hour-segment rendering is reached only under discrete hours, which have
|
|
1331
1679
|
// segments.
|
|
1332
|
-
const segments = ir
|
|
1680
|
+
const segments = segmentsOf(ir, 'hour');
|
|
1333
1681
|
const plain = mixedTwelve(segments.flatMap(function entries(segment) {
|
|
1334
1682
|
return segmentHours(segment).map(function entry(hour) {
|
|
1335
1683
|
return {hour: +hour, minute, second};
|
|
@@ -1376,22 +1724,35 @@ function disambiguateTimes(pieces: string[], segments: Segment[],
|
|
|
1376
1724
|
});
|
|
1377
1725
|
}
|
|
1378
1726
|
|
|
1379
|
-
// Join a list with commas and a terminal
|
|
1380
|
-
// adds a serial comma before the
|
|
1727
|
+
// Join a list with commas and a terminal conjunction. The US dialect (Chicago)
|
|
1728
|
+
// adds a serial comma before the conjunction in lists of three or more; the UK
|
|
1381
1729
|
// dialect (Guardian) does not. Pairs never take one.
|
|
1382
|
-
function
|
|
1730
|
+
function joinWith(items: (string | number)[], conjunction: string,
|
|
1383
1731
|
opts: NormalizedOptions): string {
|
|
1384
1732
|
if (items.length <= 1) {
|
|
1385
1733
|
return items.join('');
|
|
1386
1734
|
}
|
|
1387
1735
|
|
|
1388
1736
|
if (items.length === 2) {
|
|
1389
|
-
return items[0] +
|
|
1737
|
+
return items[0] + conjunction + items[1];
|
|
1390
1738
|
}
|
|
1391
1739
|
|
|
1392
|
-
const
|
|
1740
|
+
const tail = opts.style.serialComma ? ',' + conjunction : conjunction;
|
|
1393
1741
|
|
|
1394
|
-
return items.slice(0, -1).join(', ') +
|
|
1742
|
+
return items.slice(0, -1).join(', ') + tail + items[items.length - 1];
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// Join a list with a terminal "and" (the default English connective).
|
|
1746
|
+
function joinList(items: (string | number)[],
|
|
1747
|
+
opts: NormalizedOptions): string {
|
|
1748
|
+
return joinWith(items, ' and ', opts);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// Join a list with a terminal "or", for an alternation such as a day-union
|
|
1752
|
+
// predicate list ("the 1st, a Sunday, or a weekday").
|
|
1753
|
+
function joinOr(items: (string | number)[],
|
|
1754
|
+
opts: NormalizedOptions): string {
|
|
1755
|
+
return joinWith(items, ' or ', opts);
|
|
1395
1756
|
}
|
|
1396
1757
|
|
|
1397
1758
|
// --- Day-level qualifiers. ---
|
|
@@ -1405,13 +1766,19 @@ interface QualifierWords {
|
|
|
1405
1766
|
month: string;
|
|
1406
1767
|
stepDate: string;
|
|
1407
1768
|
weekday: string;
|
|
1769
|
+
// A trailing weekday is a recurring schedule and reads plural ("on
|
|
1770
|
+
// Mondays"); a leading time-anchored one names the day singular ("every
|
|
1771
|
+
// Monday at 9 a.m.").
|
|
1772
|
+
recurringWeekday: boolean;
|
|
1408
1773
|
}
|
|
1409
1774
|
|
|
1410
|
-
const trailingWords: QualifierWords =
|
|
1411
|
-
|
|
1775
|
+
const trailingWords: QualifierWords = {
|
|
1776
|
+
all: '', month: 'in ', recurringWeekday: true, stepDate: 'on ', weekday: 'on '
|
|
1777
|
+
};
|
|
1412
1778
|
const leadingWords: QualifierWords = {
|
|
1413
1779
|
all: 'every day',
|
|
1414
1780
|
month: 'every day in ',
|
|
1781
|
+
recurringWeekday: false,
|
|
1415
1782
|
stepDate: '',
|
|
1416
1783
|
weekday: 'every '
|
|
1417
1784
|
};
|
|
@@ -1419,6 +1786,13 @@ const leadingWords: QualifierWords = {
|
|
|
1419
1786
|
// A trailing day-level qualifier for bare frequencies, e.g. " on Monday".
|
|
1420
1787
|
// Returns an empty string when no date, month, or weekday is set.
|
|
1421
1788
|
function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
|
|
1789
|
+
// A day union reframes both day fields as a trailing condition clause; the
|
|
1790
|
+
// month leads the whole description (applied in `describe`), so it is not
|
|
1791
|
+
// part of the trailing qualifier here.
|
|
1792
|
+
if (isDayUnion(ir, opts)) {
|
|
1793
|
+
return dayUnionCondition(ir, opts);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1422
1796
|
const phrase = dayQualifier(ir, trailingWords, opts);
|
|
1423
1797
|
|
|
1424
1798
|
return phrase && ' ' + phrase;
|
|
@@ -1427,6 +1801,13 @@ function trailingQualifier(ir: IR, opts: NormalizedOptions): string {
|
|
|
1427
1801
|
// Build the day-level qualifier that precedes a specific time, e.g.
|
|
1428
1802
|
// "every day ", "every Friday ", or "on January 13 ".
|
|
1429
1803
|
function interpretDayQualifier(ir: IR, opts: NormalizedOptions): string {
|
|
1804
|
+
// A day union puts the time first ("at midnight whenever the day is …"), so
|
|
1805
|
+
// the leading position contributes no day phrase; the condition clause is
|
|
1806
|
+
// appended after the time by the clock renderer.
|
|
1807
|
+
if (isDayUnion(ir, opts)) {
|
|
1808
|
+
return '';
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1430
1811
|
return dayQualifier(ir, leadingWords, opts) + ' ';
|
|
1431
1812
|
}
|
|
1432
1813
|
|
|
@@ -1450,8 +1831,17 @@ function dayQualifier(ir: IR, words: QualifierWords,
|
|
|
1450
1831
|
// A weekday qualifier, optionally scoped to a month ("on Monday in
|
|
1451
1832
|
// June").
|
|
1452
1833
|
if (pattern.weekday !== '*') {
|
|
1453
|
-
const
|
|
1454
|
-
|
|
1834
|
+
const quartzWeekday = quartzWeekdayPhrase(pattern.weekday, opts);
|
|
1835
|
+
|
|
1836
|
+
// The Quartz weekday phrase ("on the last Friday of the month") carries
|
|
1837
|
+
// the "of the month" recurrence a concrete month makes redundant; a plain
|
|
1838
|
+
// weekday name takes the ordinary " in <month>" scope.
|
|
1839
|
+
if (quartzWeekday) {
|
|
1840
|
+
return monthScopeForRecurrence(quartzWeekday, ir, opts);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const weekdays = words.weekday +
|
|
1844
|
+
weekdayPhrase(ir, words.recurringWeekday, opts);
|
|
1455
1845
|
|
|
1456
1846
|
return weekdays + monthScope(ir, opts);
|
|
1457
1847
|
}
|
|
@@ -1470,11 +1860,12 @@ function datePhrase(ir: IR, words: QualifierWords,
|
|
|
1470
1860
|
const quartzDate = quartzDatePhrase(pattern.date, opts);
|
|
1471
1861
|
|
|
1472
1862
|
if (quartzDate) {
|
|
1473
|
-
return quartzDate
|
|
1863
|
+
return monthScopeForRecurrence(quartzDate, ir, opts);
|
|
1474
1864
|
}
|
|
1475
1865
|
|
|
1476
1866
|
if (isOpenStep(pattern.date)) {
|
|
1477
|
-
return
|
|
1867
|
+
return monthScopeForRecurrence(
|
|
1868
|
+
words.stepDate + stepDates(pattern.date), ir, opts);
|
|
1478
1869
|
}
|
|
1479
1870
|
|
|
1480
1871
|
if (pattern.month !== '*' && !monthFoldsIntoDate(ir)) {
|
|
@@ -1497,11 +1888,153 @@ function datePhrase(ir: IR, words: QualifierWords,
|
|
|
1497
1888
|
function monthFoldsIntoDate(ir: IR): boolean {
|
|
1498
1889
|
return !oddEvenMonth(ir.pattern.month) &&
|
|
1499
1890
|
// Reached only with a restricted month, which has segments.
|
|
1500
|
-
ir.
|
|
1891
|
+
segmentsOf(ir, 'month').every(function flat(segment) {
|
|
1501
1892
|
return segment.kind !== 'range';
|
|
1502
1893
|
});
|
|
1503
1894
|
}
|
|
1504
1895
|
|
|
1896
|
+
// When BOTH the date and weekday are restricted, cron fires on the UNION of
|
|
1897
|
+
// the two day sets — a point the old "on <dom> or on <dow>" form blurred,
|
|
1898
|
+
// reading as alternatives (or, with "and", as an intersection). The default
|
|
1899
|
+
// dialect reframes the union as a predicate over a single variable, the day:
|
|
1900
|
+
// "whenever the day is <dom-predicate> or <dow-predicate(s)>", a flat or-list
|
|
1901
|
+
// that reads as a union for naive, logical, and technical readers alike. The
|
|
1902
|
+
// month leads the whole clause ("in June …") and the time/cadence sits between
|
|
1903
|
+
// the two, so this form is composed at the top level (see `dayUnionMonthLead`
|
|
1904
|
+
// and `dayUnionCondition`), not inside the trailing/leading qualifier. Scoped
|
|
1905
|
+
// to the until-window dialect; every other dialect and the `short` form keep
|
|
1906
|
+
// the established "on <dom> or on <dow>" phrasing.
|
|
1907
|
+
function isDayUnion(ir: IR, opts: NormalizedOptions): boolean {
|
|
1908
|
+
return ir.pattern.date !== '*' && ir.pattern.weekday !== '*' &&
|
|
1909
|
+
!!opts.style.untilWindow && !opts.short;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// The trailing condition clause for a day union, e.g. " whenever the day is
|
|
1913
|
+
// the 1st or a Friday". The day predicates are flattened into one or-list so
|
|
1914
|
+
// the union reads as a single set of matching days.
|
|
1915
|
+
function dayUnionCondition(ir: IR, opts: NormalizedOptions): string {
|
|
1916
|
+
const pieces = [...dayUnionDatePieces(ir, opts),
|
|
1917
|
+
...dayUnionWeekdayPieces(ir, opts)];
|
|
1918
|
+
|
|
1919
|
+
return ' whenever the day is ' + joinOr(pieces, opts);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// The leading "in <month> " scope for a day union, or an empty string when the
|
|
1923
|
+
// month is a wildcard. The month scopes the whole union, so it leads the clause
|
|
1924
|
+
// rather than attaching to either day half.
|
|
1925
|
+
function dayUnionMonthLead(ir: IR, opts: NormalizedOptions): string {
|
|
1926
|
+
if (ir.pattern.month === '*') {
|
|
1927
|
+
return '';
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
return 'in ' + monthName(ir, opts) + ' ';
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// The day-of-month half of a union as a flat list of predicate pieces. A
|
|
1934
|
+
// Quartz date is its definite phrase ("the last day of the month"); an open
|
|
1935
|
+
// `*/2`-style step is the parity idiom ("an odd-numbered day"); a plain field
|
|
1936
|
+
// reads each segment as "the <ordinal>" or "from the <ordinal> through the
|
|
1937
|
+
// <ordinal>".
|
|
1938
|
+
function dayUnionDatePieces(ir: IR, opts: NormalizedOptions): string[] {
|
|
1939
|
+
const dateField = ir.pattern.date;
|
|
1940
|
+
const quartz = quartzDatePhrase(dateField, opts);
|
|
1941
|
+
|
|
1942
|
+
if (quartz) {
|
|
1943
|
+
return [quartz.replace(/^on /, '')];
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
const oddEven = oddEvenDay(dateField);
|
|
1947
|
+
|
|
1948
|
+
if (oddEven) {
|
|
1949
|
+
return [oddEven];
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// Reached only with a restricted, non-Quartz date, which has segments. Each
|
|
1953
|
+
// segment contributes its predicate piece(s) to the flat union list; a step
|
|
1954
|
+
// spreads its enumerated fires as separate "the <ordinal>" alternatives.
|
|
1955
|
+
const pieces: string[] = [];
|
|
1956
|
+
|
|
1957
|
+
segmentsOf(ir, 'date').forEach(function expand(segment) {
|
|
1958
|
+
if (segment.kind === 'range') {
|
|
1959
|
+
pieces.push('from the ' + getOrdinal(segment.bounds[0]) + through(opts) +
|
|
1960
|
+
'the ' + getOrdinal(segment.bounds[1]));
|
|
1961
|
+
}
|
|
1962
|
+
else if (segment.kind === 'step') {
|
|
1963
|
+
segment.fires.forEach(function fire(value) {
|
|
1964
|
+
pieces.push('the ' + getOrdinal(value));
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
else {
|
|
1968
|
+
pieces.push('the ' + getOrdinal(segment.value));
|
|
1969
|
+
}
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
return pieces;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// The day-of-week half of a union as a flat list of predicate pieces. A Quartz
|
|
1976
|
+
// weekday is its definite phrase ("the last Friday of the month"); the Monday-
|
|
1977
|
+
// through-Friday range is the "a weekday" idiom; every other weekday names each
|
|
1978
|
+
// day with the indefinite article ("a Friday", "a Sunday"), so each reads as a
|
|
1979
|
+
// kind of day the union can match.
|
|
1980
|
+
function dayUnionWeekdayPieces(ir: IR, opts: NormalizedOptions): string[] {
|
|
1981
|
+
const weekdayField = ir.pattern.weekday;
|
|
1982
|
+
const quartz = quartzWeekdayPhrase(weekdayField, opts);
|
|
1983
|
+
|
|
1984
|
+
if (quartz) {
|
|
1985
|
+
return [quartz.replace(/^on /, '')];
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// The union predicate keeps the canonical Sunday-first order (0…6) rather
|
|
1989
|
+
// than the weekend-last display order: as a flat or-list of day kinds, the
|
|
1990
|
+
// numeric order reads as naturally as any other in a flat or-list ("a
|
|
1991
|
+
// Sunday, a Tuesday, a Thursday, or a Saturday").
|
|
1992
|
+
const pieces: string[] = [];
|
|
1993
|
+
|
|
1994
|
+
segmentsOf(ir, 'weekday').forEach(function expand(segment) {
|
|
1995
|
+
if (segment.kind === 'range' &&
|
|
1996
|
+
segment.bounds[0] === '1' && segment.bounds[1] === '5') {
|
|
1997
|
+
pieces.push('a weekday');
|
|
1998
|
+
}
|
|
1999
|
+
else if (segment.kind === 'range') {
|
|
2000
|
+
pieces.push('a ' + getWeekday(segment.bounds[0], opts) + through(opts) +
|
|
2001
|
+
'a ' + getWeekday(segment.bounds[1], opts));
|
|
2002
|
+
}
|
|
2003
|
+
else if (segment.kind === 'step') {
|
|
2004
|
+
segment.fires.forEach(function fire(value) {
|
|
2005
|
+
pieces.push('a ' + getWeekday(value, opts));
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
else {
|
|
2009
|
+
pieces.push('a ' + getWeekday(segment.value, opts));
|
|
2010
|
+
}
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
return pieces;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// An interval-2 day-of-month step covering a parity set reads as "an
|
|
2017
|
+
// odd/even-numbered day", mirroring the month and year parity idioms: `*/2`
|
|
2018
|
+
// and `1/2` are the odd days, `2/2` the even; any other start enumerates
|
|
2019
|
+
// instead. Null when the field is not an open interval-2 step.
|
|
2020
|
+
function oddEvenDay(dateField: string): string | null {
|
|
2021
|
+
if (!isOpenStep(dateField)) {
|
|
2022
|
+
return null;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
const [start, step] = dateField.split('/');
|
|
2026
|
+
|
|
2027
|
+
if (+step !== 2) {
|
|
2028
|
+
return null;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
if (start === '*' || start === '1') {
|
|
2032
|
+
return 'an odd-numbered day';
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
return start === '2' ? 'an even-numbered day' : null;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
1505
2038
|
// Compose the "day-of-month or day-of-week" phrase used when both fields
|
|
1506
2039
|
// are restricted: cron fires when either is a match. A restricted month
|
|
1507
2040
|
// scopes BOTH halves, so it attaches to the whole or, never to a single
|
|
@@ -1511,8 +2044,10 @@ function monthFoldsIntoDate(ir: IR): boolean {
|
|
|
1511
2044
|
// odd/even frequency) it trails the whole or as ", in <month>".
|
|
1512
2045
|
function dateOrWeekday(ir: IR, opts: NormalizedOptions): string {
|
|
1513
2046
|
const pattern = ir.pattern;
|
|
2047
|
+
// The day-of-month-OR-day-of-week union is out of scope for the recurring
|
|
2048
|
+
// plural (it is reframed elsewhere): the weekday half stays singular here.
|
|
1514
2049
|
const weekdayPart = quartzWeekdayPhrase(pattern.weekday, opts) ||
|
|
1515
|
-
'on ' + weekdayPhrase(ir, opts);
|
|
2050
|
+
'on ' + weekdayPhrase(ir, false, opts);
|
|
1516
2051
|
|
|
1517
2052
|
if (pattern.month !== '*' && monthFoldsIntoDate(ir) &&
|
|
1518
2053
|
!quartzDatePhrase(pattern.date, opts) && !isOpenStep(pattern.date)) {
|
|
@@ -1599,13 +2134,24 @@ function quartzWeekdayPhrase(weekdayField: string,
|
|
|
1599
2134
|
// A calendar date with its month, in the dialect's order and day form:
|
|
1600
2135
|
// cardinal "January 1" / "1 January", or ordinal "January 1st" for
|
|
1601
2136
|
// dialects that set `ordinals`.
|
|
2137
|
+
//
|
|
2138
|
+
// A day-first dialect places the day before the month, but a single day before
|
|
2139
|
+
// a MULTI-month list garden-paths — "13 January, April, July and October"
|
|
2140
|
+
// reads as if the 13 belongs to January alone. The day is reattached to the
|
|
2141
|
+
// whole list with the possessive "the <ordinal> of <months>", which names the
|
|
2142
|
+
// same day across every month unambiguously.
|
|
1602
2143
|
function monthDatePhrase(ir: IR, opts: NormalizedOptions): string {
|
|
1603
2144
|
const month = monthName(ir, opts);
|
|
1604
2145
|
// A month-day phrase is reached only with a restricted date, which has
|
|
1605
2146
|
// segments.
|
|
1606
|
-
const days = renderSegments(ir
|
|
2147
|
+
const days = renderSegments(segmentsOf(ir, 'date'),
|
|
1607
2148
|
opts.style.ordinals ? getOrdinal : cardinalDay, opts);
|
|
1608
2149
|
|
|
2150
|
+
if (opts.style.dayFirst && ir.shapes.date === 'single' &&
|
|
2151
|
+
ir.shapes.month !== 'single') {
|
|
2152
|
+
return 'the ' + getOrdinal(ir.pattern.date) + ' of ' + month;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
1609
2155
|
return opts.style.dayFirst ? days + ' ' + month : month + ' ' + days;
|
|
1610
2156
|
}
|
|
1611
2157
|
|
|
@@ -1624,6 +2170,35 @@ function monthScope(ir: IR, opts: NormalizedOptions): string {
|
|
|
1624
2170
|
return ' in ' + monthName(ir, opts);
|
|
1625
2171
|
}
|
|
1626
2172
|
|
|
2173
|
+
// Scope a phrase that ends in the recurrence "of the month" (the Quartz last-
|
|
2174
|
+
// day / last-weekday / nth-weekday forms and the open day-of-month step) by a
|
|
2175
|
+
// named month. A concrete month — a single name or a step ("every odd-numbered
|
|
2176
|
+
// month", "January, April, …") — makes "of the month" redundant: it names that
|
|
2177
|
+
// one month, so the phrase drops it and reads "in <month>". A month RANGE
|
|
2178
|
+
// distributes the recurrence across the span and keeps it, rephrased as "of
|
|
2179
|
+
// each month from <first> through <last>". A month list is left as-is (the
|
|
2180
|
+
// recurrence stays, scoped "in <names>"), and a wildcard month adds nothing.
|
|
2181
|
+
function monthScopeForRecurrence(phrase: string, ir: IR,
|
|
2182
|
+
opts: NormalizedOptions): string {
|
|
2183
|
+
if (ir.pattern.month === '*') {
|
|
2184
|
+
return phrase;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
const carriesRecurrence = phrase.indexOf(' of the month') !== -1;
|
|
2188
|
+
|
|
2189
|
+
if (carriesRecurrence && ir.shapes.month === 'range') {
|
|
2190
|
+
return phrase.replace(' of the month', ' of each month') + ' from ' +
|
|
2191
|
+
monthName(ir, opts);
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
if (carriesRecurrence &&
|
|
2195
|
+
(ir.shapes.month === 'single' || ir.shapes.month === 'step')) {
|
|
2196
|
+
return phrase.replace(' of the month', '') + ' in ' + monthName(ir, opts);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
return phrase + ' in ' + monthName(ir, opts);
|
|
2200
|
+
}
|
|
2201
|
+
|
|
1627
2202
|
// Frequency phrase for an open day-of-month step, e.g. "every other day of
|
|
1628
2203
|
// the month" or "every 3rd day of the month from the 5th".
|
|
1629
2204
|
function stepDates(dateField: string): string {
|
|
@@ -1646,7 +2221,7 @@ function stepDates(dateField: string): string {
|
|
|
1646
2221
|
// handled separately as a frequency phrase.
|
|
1647
2222
|
function dateOrdinals(ir: IR, opts: NormalizedOptions): string {
|
|
1648
2223
|
// Reached only with a restricted date, which has segments.
|
|
1649
|
-
return renderSegments(ir
|
|
2224
|
+
return renderSegments(segmentsOf(ir, 'date'), getOrdinal, opts);
|
|
1650
2225
|
}
|
|
1651
2226
|
|
|
1652
2227
|
// Render the month field as names. There are few, named months, so a step
|
|
@@ -1662,7 +2237,7 @@ function monthName(ir: IR, opts: NormalizedOptions): string {
|
|
|
1662
2237
|
|
|
1663
2238
|
// A restricted month has segments; open steps of interval 3+ enumerate their
|
|
1664
2239
|
// fires here too.
|
|
1665
|
-
return renderSegments(ir
|
|
2240
|
+
return renderSegments(segmentsOf(ir, 'month'), function name(value) {
|
|
1666
2241
|
return getMonth(value, opts);
|
|
1667
2242
|
}, opts);
|
|
1668
2243
|
}
|
|
@@ -1690,16 +2265,44 @@ function oddEvenMonth(monthField: string): string | null {
|
|
|
1690
2265
|
}
|
|
1691
2266
|
|
|
1692
2267
|
// Render the weekday field as names. Ranges read in their connective form
|
|
1693
|
-
// ("Monday through Friday", or "Mon-Fri" with `short`).
|
|
1694
|
-
|
|
2268
|
+
// ("Monday through Friday", or "Mon-Fri" with `short`). When `recurring`, a
|
|
2269
|
+
// trailing single or list weekday is a repeating schedule and reads plural
|
|
2270
|
+
// ("on Mondays", "on Mondays and Wednesdays"), matching es/de/fi; a RANGE
|
|
2271
|
+
// keeps the singular idiom ("on Monday through Friday") so its through-
|
|
2272
|
+
// connective stays unmistakable, and a leading time-anchored form ("every
|
|
2273
|
+
// Monday") is never recurring here.
|
|
2274
|
+
function weekdayPhrase(ir: IR, recurring: boolean,
|
|
2275
|
+
opts: NormalizedOptions): string {
|
|
1695
2276
|
// Reached only with a restricted weekday, which has segments. Weekday lists
|
|
1696
2277
|
// display Monday-first (Sunday last) so a weekend reads naturally; the IR
|
|
1697
2278
|
// stays canonical (Sunday=0) and ranges keep their form.
|
|
1698
|
-
const segments = orderWeekdaysForDisplay(ir
|
|
2279
|
+
const segments = orderWeekdaysForDisplay(segmentsOf(ir, 'weekday'));
|
|
2280
|
+
const hasRange = segments.some(function range(segment) {
|
|
2281
|
+
return segment.kind === 'range';
|
|
2282
|
+
});
|
|
1699
2283
|
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
2284
|
+
// A range pins the singular idiom for the whole phrase ("Monday through
|
|
2285
|
+
// Friday"); only an all-single/step set pluralizes its names.
|
|
2286
|
+
const name = recurring && !hasRange ?
|
|
2287
|
+
function plural(value: number | string): string {
|
|
2288
|
+
return pluralWeekday(value, opts);
|
|
2289
|
+
} :
|
|
2290
|
+
function singular(value: number | string): string {
|
|
2291
|
+
return getWeekday(value, opts);
|
|
2292
|
+
};
|
|
2293
|
+
|
|
2294
|
+
return renderSegments(segments, name, opts);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// The recurring (plural) form of a weekday name: every English weekday name
|
|
2298
|
+
// pluralizes by appending "s" ("Mondays", "Sundays"). The `short`
|
|
2299
|
+
// abbreviation keeps its singular form — "on Mons" reads as an error, not a
|
|
2300
|
+
// plural.
|
|
2301
|
+
function pluralWeekday(value: number | string,
|
|
2302
|
+
opts: NormalizedOptions): string {
|
|
2303
|
+
const name = getWeekday(value, opts);
|
|
2304
|
+
|
|
2305
|
+
return opts.short ? name : name + 's';
|
|
1703
2306
|
}
|
|
1704
2307
|
|
|
1705
2308
|
// Render classified field segments with `word`, expanding step segments
|
|
@@ -1725,14 +2328,6 @@ function renderSegments(segments: Segment[],
|
|
|
1725
2328
|
return joinList(pieces, opts);
|
|
1726
2329
|
}
|
|
1727
2330
|
|
|
1728
|
-
// Whether a canonical field value is an "open" step (`*/n` or `a/n`, not a
|
|
1729
|
-
// bounded range or a list). Open steps read as a frequency rather than an
|
|
1730
|
-
// enumeration.
|
|
1731
|
-
function isOpenStep(field: string): boolean {
|
|
1732
|
-
return field.indexOf('/') !== -1 && field.indexOf('-') === -1 &&
|
|
1733
|
-
field.indexOf(',') === -1;
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
2331
|
// --- Years. ---
|
|
1737
2332
|
|
|
1738
2333
|
// Append or fold the year field into a finished description. An
|
|
@@ -1746,7 +2341,10 @@ function applyYear(description: string, ir: IR,
|
|
|
1746
2341
|
}
|
|
1747
2342
|
|
|
1748
2343
|
if (yearField.indexOf('/') !== -1) {
|
|
1749
|
-
|
|
2344
|
+
// A year step is a coarser cadence juxtaposed on the finished clause: a
|
|
2345
|
+
// clause comma separates it ("every second, every other year"), matching
|
|
2346
|
+
// how every other juxtaposed clause is joined.
|
|
2347
|
+
return description + ', ' + stepYears(yearField, opts);
|
|
1750
2348
|
}
|
|
1751
2349
|
|
|
1752
2350
|
const label = yearLabel(yearField, opts);
|
|
@@ -1769,6 +2367,12 @@ function yearLabel(yearField: string, opts: NormalizedOptions): string {
|
|
|
1769
2367
|
return joinList(yearField.split(','), opts);
|
|
1770
2368
|
}
|
|
1771
2369
|
|
|
2370
|
+
// A year range reads with the dialect's range connective ("2030 through
|
|
2371
|
+
// 2035"), the same form every other field uses, not a raw hyphen.
|
|
2372
|
+
if (yearField.indexOf('-') !== -1) {
|
|
2373
|
+
return yearField.split('-').join(through(opts));
|
|
2374
|
+
}
|
|
2375
|
+
|
|
1772
2376
|
return yearField;
|
|
1773
2377
|
}
|
|
1774
2378
|
|
|
@@ -1783,7 +2387,11 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
|
|
|
1783
2387
|
return 'every year';
|
|
1784
2388
|
}
|
|
1785
2389
|
|
|
1786
|
-
|
|
2390
|
+
// Interval 2 reads as the parity idiom ("every other year"), matching the
|
|
2391
|
+
// month and day-of-month step forms; longer intervals count the years.
|
|
2392
|
+
let phrase = interval === 2 ?
|
|
2393
|
+
'every other year' :
|
|
2394
|
+
'every ' + getNumber(interval, opts) + ' years';
|
|
1787
2395
|
|
|
1788
2396
|
if (start !== '*' && start !== '0') {
|
|
1789
2397
|
phrase += ' from ' + start;
|