cronli5 0.3.1 → 0.7.2

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.
@@ -0,0 +1,2115 @@
1
+ // The French language module: renders an analyzed cron pattern (the Schedule
2
+ // produced by core `analyze`) as natural French. Anchored to the fr-FR norm
3
+ // (Imprimerie nationale / Académie française, plus cronstrue `fr`); see
4
+ // notes.md
5
+ // for the decisions and trade-offs.
6
+ //
7
+ // fr is sibling-derived from es (docs/i18n-design.md §7, the language
8
+ // pipeline): it ports the Spanish module's STRUCTURE — the plan override, the
9
+ // OR-union frame, the parity predicates, the re-strategies, the dialect
10
+ // mechanism — and translates the lexicon, then diverges where French grammar
11
+ // genuinely differs: the "9 h 30" 24-hour clock (no article, unpadded, spaced
12
+ // "h", minuit/midi, the 12-hour machinery dropped and {ampm} a no-op),
13
+ // preposition+article contraction (du/des/au/aux), the per-value "le 1er"
14
+ // ordinal, gender agreement (masculine weekdays, gendered Quartz ordinals, the
15
+ // agreeing cadence determiner), the "le lundi" singular-definite recurrence,
16
+ // the "soit X soit Y" union, and no comma before "et". A language never imports
17
+ // another (this is a copy-and-translate of es, not an import); the only shared
18
+ // dependency is core. pt's analogous Romance layer (contractions, gender) was a
19
+ // reference, never imported.
20
+
21
+ import {numeral, pad} from '../../core/format.js';
22
+ import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
23
+ import {isOpenStep} from '../../core/shapes.js';
24
+ import {
25
+ arithmeticStep, hourListStride, offsetCleanStride,
26
+ renderStride as chooseStride, segmentsOf, singleValues, stepSegment
27
+ } from '../../core/cadence.js';
28
+ import {orderWeekdaysForDisplay} from '../../core/weekday.js';
29
+ import {toFieldNumber} from '../../core/util.js';
30
+ import type {Cronli5Options} from '../../types.js';
31
+ import type {
32
+ HourTimesPlan, Schedule, Language, NormalizedOptions, PlanNode,
33
+ Segment
34
+ } from '../../core/schedule.js';
35
+ import {resolveDialect, type FrenchStyle} from './dialects.js';
36
+
37
+ // Normalized options carrying French's own style shape.
38
+ type Opts = NormalizedOptions<FrenchStyle>;
39
+
40
+ // The erased renderer signature the dispatch table maps to.
41
+ type Renderer = (schedule: Schedule, plan: PlanNode, opts: Opts) => string;
42
+
43
+ // A `step` segment, narrowed from the discriminated `Segment` union.
44
+ type StepSegment = Extract<Segment, {kind: 'step'}>;
45
+
46
+ // A step cadence to phrase: the `interval` repeats over a `cycle`-long field
47
+ // (60 for minute/second), running from `start` to `last`. `unit` is the
48
+ // singular noun and `anchor` the larger unit the values count against. When
49
+ // `anchor` is empty the caller supplies its own trailing scope, so the cadence
50
+ // drops the "de chaque <anchor>" tail.
51
+ interface Stride {
52
+ interval: number;
53
+ start: number;
54
+ last: number;
55
+ cycle: number;
56
+ unit: string;
57
+ anchor: string;
58
+ }
59
+
60
+ // One end of a clock-time range. The second is optional and may be absent
61
+ // (top-of-hour windows) or a folded clock second.
62
+ type ClockEnd = {hour: number; minute: number; second?: number | null};
63
+
64
+ // A name token: a cron name or numeric string from a segment, or a numeric
65
+ // fire that `flattenSteps` expands a step into.
66
+ type NameToken = string | number;
67
+
68
+ // A flattened name segment. `flattenSteps` turns step segments into single
69
+ // segments whose `value` is a numeric fire, so a single's value here may be
70
+ // a number as well as a `Segment`'s string token; ranges keep their bounds.
71
+ type NameSegment =
72
+ | {kind: 'single'; value: NameToken}
73
+ | {kind: 'range'; bounds: [string, string]};
74
+
75
+ // The range and single arms of a flattened name segment.
76
+ type RangeNameSegment = Extract<NameSegment, {kind: 'range'}>;
77
+ type SingleNameSegment = Extract<NameSegment, {kind: 'single'}>;
78
+
79
+
80
+ // French number names for the integers zero through ten.
81
+ const numeros = [
82
+ 'zéro',
83
+ 'un',
84
+ 'deux',
85
+ 'trois',
86
+ 'quatre',
87
+ 'cinq',
88
+ 'six',
89
+ 'sept',
90
+ 'huit',
91
+ 'neuf',
92
+ 'dix'
93
+ ];
94
+
95
+ // French month names (lowercase, per Académie/IN). Masculine.
96
+ const monthNames = [
97
+ null,
98
+ 'janvier',
99
+ 'février',
100
+ 'mars',
101
+ 'avril',
102
+ 'mai',
103
+ 'juin',
104
+ 'juillet',
105
+ 'août',
106
+ 'septembre',
107
+ 'octobre',
108
+ 'novembre',
109
+ 'décembre'
110
+ ];
111
+
112
+ // French weekday names (lowercase, per Académie/IN). All masculine (le lundi).
113
+ const weekdayNames = [
114
+ 'dimanche',
115
+ 'lundi',
116
+ 'mardi',
117
+ 'mercredi',
118
+ 'jeudi',
119
+ 'vendredi',
120
+ 'samedi'
121
+ ];
122
+
123
+ // Gendered ordinals for Quartz `#` weekday occurrences (1-5). French nth
124
+ // ordinals agree with the target noun; weekdays and "jour" are masculine, so
125
+ // the masculine row is the common case. The feminine row is kept for any
126
+ // feminine target noun the renderer might gain (e.g. a week-scoped form).
127
+ const nthWeekdayMasculine =
128
+ [null, 'premier', 'deuxième', 'troisième', 'quatrième', 'cinquième'];
129
+
130
+ // Normalize raw user options.
131
+ function normalizeOptions(options?: Cronli5Options): Opts {
132
+ options = options || {};
133
+ const style = resolveDialect(options.dialect);
134
+
135
+ return {
136
+ // fr is 24-hour only; `ampm` is accepted and ignored (a documented no-op,
137
+ // notes.md). It is normalized to false so the shared option shape is
138
+ // satisfied without the 12-hour machinery the es donor carried.
139
+ ampm: false,
140
+ lenient: !!options.lenient,
141
+ seconds: !!options.seconds,
142
+ short: !!options.short,
143
+ style,
144
+ years: !!options.years
145
+ };
146
+ }
147
+
148
+ // Render an analyzed cron pattern (the Schedule) as French.
149
+ function describe(schedule: Schedule, opts: Opts): string {
150
+ return applyYear(render(schedule, schedule.plan, opts), schedule, opts);
151
+ }
152
+
153
+ // Render one plan node. `composeSeconds` recurses with its `rest` plan.
154
+ // When BOTH date and weekday are restricted (a date-OR-weekday union), the
155
+ // result is wrapped in the unified `[month] [time], soit <DOM> soit <DOW>`
156
+ // frame regardless of arm shapes or month type.
157
+ function render(schedule: Schedule, plan: PlanNode, opts: Opts): string {
158
+ // Each renderer narrows `plan` to its own `kind`; the dispatch table is
159
+ // keyed by that discriminant, so the union-to-specific match is sound but
160
+ // not expressible without a cast.
161
+ const phrase = (renderers[plan.kind] as Renderer)(schedule, plan, opts);
162
+
163
+ if (!isDateWeekdayUnion(schedule)) {
164
+ return phrase;
165
+ }
166
+
167
+ // The time/frequency phrase arrives from the renderer with no day qualifier
168
+ // (leadingQualifier and trailingQualifier both return '' for union patterns).
169
+ // Front the shared month (possibly with a trailing comma for enumerations),
170
+ // then append the union correlative last.
171
+ const lead = unionMonthLeadFull(schedule);
172
+
173
+ return (lead ? lead + ' ' : '') + phrase + unionSoitSuffix(schedule, opts);
174
+ }
175
+
176
+ // --- Seconds renderers. ---
177
+
178
+ function renderEverySecond(
179
+ schedule: Schedule,
180
+ plan: Extract<PlanNode, {kind: 'everySecond'}>,
181
+ opts: Opts
182
+ ): string {
183
+ return 'chaque seconde' + trailingQualifier(schedule, opts);
184
+ }
185
+
186
+ function renderStandaloneSeconds(
187
+ schedule: Schedule,
188
+ plan: Extract<PlanNode, {kind: 'standaloneSeconds'}>,
189
+ opts: Opts
190
+ ): string {
191
+ return secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
192
+ }
193
+
194
+ function renderSecondPastMinute(
195
+ schedule: Schedule,
196
+ plan: Extract<PlanNode, {kind: 'secondPastMinute'}>,
197
+ opts: Opts
198
+ ): string {
199
+ return 'à la seconde ' + schedule.pattern.second + ' de chaque minute' +
200
+ trailingQualifier(schedule, opts);
201
+ }
202
+
203
+ // A meaningful second combined with a single specific minute (and an open
204
+ // hour): a single second folds into the minute anchor; a list, range, or
205
+ // step leads with its own clause.
206
+ function renderSecondsWithinMinute(
207
+ schedule: Schedule,
208
+ plan: Extract<PlanNode, {kind: 'secondsWithinMinute'}>,
209
+ opts: Opts
210
+ ): string {
211
+ const minuteField = schedule.pattern.minute;
212
+
213
+ if (plan.singleSecond) {
214
+ return 'à la minute ' + minuteField + ' et à la seconde ' +
215
+ schedule.pattern.second + ' de chaque heure' +
216
+ trailingQualifier(schedule, opts);
217
+ }
218
+
219
+ return secondsLeadClause(schedule, opts) + ', à la minute ' + minuteField +
220
+ ' de chaque heure' + trailingQualifier(schedule, opts);
221
+ }
222
+
223
+ // A seconds list nested into one or more fixed clock times ("..., aux
224
+ // secondes 5 et 30 de 9 h et 17 h"). An offset/uneven second step the core
225
+ // enumerated to this list reads as a stride cadence; otherwise the fires are
226
+ // listed. The clock time follows with the genitive "de", so the stride drops
227
+ // its "de chaque minute" anchor.
228
+ function secondsListAtClock(
229
+ schedule: Schedule,
230
+ rest: Extract<PlanNode, {kind: 'clockTimes'}>,
231
+ opts: Opts
232
+ ): string {
233
+ const clockPhrases = rest.times.map(function clock(time) {
234
+ return timePhrase(time.hour, time.minute, null, opts);
235
+ });
236
+ const clockList = joinList(clockPhrases);
237
+ const stride =
238
+ strideFromSegments(segmentsOf(schedule, 'second'), 'seconde', '', opts);
239
+ const secondsPhrase = stride ?? 'aux secondes ' +
240
+ joinList(segmentWords(segmentsOf(schedule, 'second')));
241
+ const dayFrame = trailingQualifier(schedule, opts);
242
+
243
+ return (dayFrame ? dayFrame.trimStart() + ', ' : '') +
244
+ secondsPhrase + ' de ' + clockList;
245
+ }
246
+
247
+ // The hour-cadence rendering of a compose-seconds plan whose clock-time rest
248
+ // would cross-multiply an hour stride under a single pinned minute, or null
249
+ // when that does not apply (a non-clock rest, a multi-valued minute, or an
250
+ // hour that is not a stride).
251
+ function composeHourCadence(
252
+ schedule: Schedule,
253
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
254
+ opts: Opts
255
+ ): string | null {
256
+ const clockRest = plan.rest.kind === 'clockTimes' ||
257
+ plan.rest.kind === 'compactClockTimes';
258
+
259
+ if (!clockRest || schedule.shapes.minute !== 'single') {
260
+ return null;
261
+ }
262
+
263
+ const minute = +schedule.pattern.minute;
264
+
265
+ return hourCadence(schedule, minute, opts) ??
266
+ hourRangeCadence(schedule, minute, opts);
267
+ }
268
+
269
+ // A wildcard or stepped second with a fixed minute across one or more specific
270
+ // hours: the seconds confine to the clock time(s), each minute named.
271
+ function isPinnedMinuteSeconds(
272
+ schedule: Schedule,
273
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
274
+ ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
275
+ {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
276
+ return plan.rest.kind === 'clockTimes' &&
277
+ (schedule.shapes.second === 'wildcard' ||
278
+ schedule.shapes.second === 'step');
279
+ }
280
+
281
+ function renderComposeSeconds(
282
+ schedule: Schedule,
283
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
284
+ opts: Opts
285
+ ): string {
286
+ // An hour step (or arithmetic-progression hour list) under a single pinned
287
+ // minute is a cadence, not a wall of clock times: the second/minute lead,
288
+ // then the hour cadence ("à la seconde 30 de chaque heure, toutes les deux
289
+ // heures"). The clock-time rest would otherwise cross-multiply the hours.
290
+ const hourCad = composeHourCadence(schedule, plan, opts);
291
+
292
+ if (hourCad !== null) {
293
+ return hourCad;
294
+ }
295
+
296
+ // A wildcard or stepped second with the minute pinned to a single value
297
+ // across one or more specific hours: the seconds confine to the clock time.
298
+ if (isPinnedMinuteSeconds(schedule, plan)) {
299
+ return pinnedMinuteSeconds(schedule, plan.rest, opts);
300
+ }
301
+
302
+ // Seconds list + fixed clock time: nest the seconds into the clock time(s)
303
+ // with genitive "de 9 h 30" instead of "de chaque minute"; the minute is
304
+ // fixed so "de chaque minute" is misleading. Single seconds already fold into
305
+ // the time in the clockTimes renderer; step seconds keep their own clause.
306
+ if (plan.rest.kind === 'clockTimes' && schedule.shapes.second === 'list') {
307
+ return secondsListAtClock(schedule, plan.rest, opts);
308
+ }
309
+
310
+ // Second-step + fixed minute + hour range + weekday: anchor the cadence to
311
+ // the minute after the weekday + hour-range frame.
312
+ if (plan.rest.kind === 'hourRange' && schedule.shapes.second === 'step' &&
313
+ schedule.pattern.weekday !== '*') {
314
+ const restNode = plan.rest;
315
+ const window = hourWindow(boundedWindow(restNode), opts);
316
+ const dayFrame = weekdayQualifier(schedule) + monthScope(schedule);
317
+ const cadence = 'toutes les ' +
318
+ numero(stepSegment(schedule, 'second').interval, opts) +
319
+ ' secondes de la minute ' + schedule.pattern.minute;
320
+
321
+ return dayFrame + ', ' + window + ', ' + cadence;
322
+ }
323
+
324
+ // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
325
+ // cadences that read as contradictory ("chaque seconde, toutes les deux
326
+ // minutes"). Bind them with the genitive "de" ("chaque seconde de chaque
327
+ // deux minutes"), mirroring English. Other strides, a restricted hour, and
328
+ // an hour cadence keep the juxtaposed form.
329
+ if (isEveryOtherMinuteSeconds(schedule, plan)) {
330
+ return secondsLeadClause(schedule, opts) + ' de ' +
331
+ render(schedule, plan.rest, opts).replace(/^toutes les /u, 'chaque ');
332
+ }
333
+
334
+ // A compact clock-time rest folds a meaningful SINGLE second into its own
335
+ // leading clause, so the composer must not prepend a second lead that would
336
+ // double it. A wildcard or stepped second is not folded there (no
337
+ // clockSecond), so it still leads its own clause here.
338
+ const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
339
+ schedule.analyses.clockSecond;
340
+ const lead = restOwnsLead ? '' : secondsLeadClause(schedule, opts) + ', ';
341
+
342
+ return lead + render(schedule, plan.rest, opts);
343
+ }
344
+
345
+ // A wildcard second over an unoffset minute */2 with a wildcard hour: the two
346
+ // cadences read as contradictory side by side, so they bind into one.
347
+ function isEveryOtherMinuteSeconds(
348
+ schedule: Schedule,
349
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
350
+ ): boolean {
351
+ if (plan.rest.kind !== 'minuteFrequency' ||
352
+ schedule.shapes.second !== 'wildcard' ||
353
+ schedule.shapes.hour !== 'wildcard') {
354
+ return false;
355
+ }
356
+
357
+ const minuteStep = stepSegment(schedule, 'minute');
358
+
359
+ return minuteStep.startToken === '*' && minuteStep.interval === 2;
360
+ }
361
+
362
+ // A wildcard or stepped second under a single pinned minute and specific
363
+ // hour(s). The clock-time rest folds the minute into the hour. Minute 0 is the
364
+ // one-minute window at the top of each named hour: a bare hour ("à 9 h") reads
365
+ // aloud as the whole hour, hiding the one-minute confinement (60 fires in the
366
+ // :00 minute, not 3,600 across the hour). A duration frame ("pendant une minute
367
+ // à 9 h") states the confinement outright, with the hour as a bare hour so it
368
+ // cannot be heard as the whole hour. A non-zero pinned minute is an unambiguous
369
+ // clock time, so the genitive "de 9 h 5" form reads it as the minute, never the
370
+ // hour.
371
+ function pinnedMinuteSeconds(
372
+ schedule: Schedule,
373
+ rest: Extract<PlanNode, {kind: 'clockTimes'}>,
374
+ opts: Opts
375
+ ): string {
376
+ const dayTrail = leadingQualifier(schedule, opts).trimEnd();
377
+ const trail = dayTrail ? ', ' + dayTrail : '';
378
+
379
+ // The "pendant une minute à 9 h" duration form drops the clock minute, so it
380
+ // is correct only when the minute is a SINGLE 0 — every clock time at :00. A
381
+ // minute LIST whose first value is 0 (e.g. */45 → :00, :45) must name each
382
+ // minute, never collapse to the bare hour, so it takes the explicit clock
383
+ // list.
384
+ if (+rest.times[0].minute === 0 && schedule.shapes.minute === 'single') {
385
+ return secondsLeadClause(schedule, opts) + ' pendant une minute ' +
386
+ durationHourList(rest.times, opts) + trail;
387
+ }
388
+
389
+ return secondsLeadClause(schedule, opts) + ' de ' +
390
+ explicitClockList(rest.times, opts) + trail;
391
+ }
392
+
393
+ // The leading clause describing a second field relative to the minute.
394
+ function secondsLeadClause(schedule: Schedule, opts: Opts): string {
395
+ return secondsClause(schedule, 'minute', opts);
396
+ }
397
+
398
+ // The second clause counted against an arbitrary anchor. The anchor is
399
+ // "minute" in the standalone seconds path; the hour-cadence path folds a pinned
400
+ // minute 0 into the hour and counts the second "de chaque heure" instead ("à la
401
+ // seconde 30 de chaque heure"), so the minute-0 confinement is stated, not
402
+ // dropped.
403
+ function secondsClause(schedule: Schedule, anchor: string, opts: Opts): string {
404
+ const secondField = schedule.pattern.second;
405
+ const shape = schedule.shapes.second;
406
+
407
+ if (secondField === '*') {
408
+ return 'chaque seconde';
409
+ }
410
+
411
+ if (shape === 'step') {
412
+ return stepCycle60(stepSegment(schedule, 'second'), 'seconde',
413
+ anchor, opts);
414
+ }
415
+
416
+ if (shape === 'range') {
417
+ const bounds = secondField.split('-');
418
+
419
+ return 'chaque seconde de ' + bounds[0] + ' à ' + bounds[1] +
420
+ ' de chaque ' + anchor;
421
+ }
422
+
423
+ if (shape === 'single') {
424
+ return 'à la seconde ' + secondField + ' de chaque ' + anchor;
425
+ }
426
+
427
+ return strideFromSegments(segmentsOf(schedule, 'second'), 'seconde', anchor,
428
+ opts) ?? 'aux secondes ' +
429
+ joinList(segmentWords(segmentsOf(schedule, 'second'))) +
430
+ ' de chaque ' + anchor;
431
+ }
432
+
433
+ // --- Minute renderers. ---
434
+
435
+ function renderEveryMinute(
436
+ schedule: Schedule,
437
+ plan: Extract<PlanNode, {kind: 'everyMinute'}>,
438
+ opts: Opts
439
+ ): string {
440
+ return 'chaque minute' + trailingQualifier(schedule, opts);
441
+ }
442
+
443
+ function renderSingleMinute(
444
+ schedule: Schedule,
445
+ plan: Extract<PlanNode, {kind: 'singleMinute'}>,
446
+ opts: Opts
447
+ ): string {
448
+ return 'à la minute ' + schedule.pattern.minute + ' de chaque heure' +
449
+ trailingQualifier(schedule, opts);
450
+ }
451
+
452
+ function renderRangeOfMinutes(
453
+ schedule: Schedule,
454
+ plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
455
+ opts: Opts
456
+ ): string {
457
+ return minuteRangeLead(schedule.pattern.minute) + ' de chaque heure' +
458
+ trailingQualifier(schedule, opts);
459
+ }
460
+
461
+ function renderMultipleMinutes(
462
+ schedule: Schedule,
463
+ plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
464
+ opts: Opts
465
+ ): string {
466
+ return minutesList(schedule, opts) + trailingQualifier(schedule, opts);
467
+ }
468
+
469
+ // "aux minutes 5, 10 et 30 de chaque heure". An offset/uneven step the core
470
+ // enumerated to this list reads as a stride cadence when the fires form a
471
+ // long-enough progression.
472
+ function minutesList(schedule: Schedule, opts: Opts): string {
473
+ return strideFromSegments(segmentsOf(schedule, 'minute'), 'minute', 'heure',
474
+ opts) ?? 'aux minutes ' +
475
+ joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de chaque heure';
476
+ }
477
+
478
+ // "chaque minute de 0 à 30". The standalone renderer adds "de chaque heure";
479
+ // when an hour qualifier follows ("..., à 9 h", "..., toutes les deux heures")
480
+ // it would contradict, so it is not baked in here.
481
+ function minuteRangeLead(minuteField: string): string {
482
+ const bounds = minuteField.split('-');
483
+
484
+ return 'chaque minute de ' + bounds[0] + ' à ' + bounds[1];
485
+ }
486
+
487
+ // Whether the hour field is a single step, which fr renders as a confinement
488
+ // phrase rather than a window list.
489
+ function singleHourStep(segments: Segment[] | null): boolean {
490
+ return segments !== null && segments.length === 1 &&
491
+ segments[0].kind === 'step';
492
+ }
493
+
494
+ // A single hour step as a confinement. A stride of two over the whole day
495
+ // reads idiomatically as the even ("les heures paires") or odd ("impaires")
496
+ // hours; any other step names its active hours, which pins the schedule
497
+ // precisely (ordinal/colloquial forms would be imprecise here).
498
+ function stepHourSpan(segment: StepSegment, opts: Opts): string {
499
+ const bounded = segment.startToken.indexOf('-') !== -1;
500
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
501
+
502
+ if (segment.interval === 2 && !bounded && start <= 1) {
503
+ return start === 0 ?
504
+ 'pendant les heures paires' :
505
+ 'pendant les heures impaires';
506
+ }
507
+
508
+ return 'pendant les heures ' + hourSpanList(segment.fires, opts);
509
+ }
510
+
511
+ // The active hours of a confined cadence: the 24-hour clock shares one "de"
512
+ // over the "X h" forms ("de 14 h, 18 h, 20 h et 22 h"). fr is 24h-only, so
513
+ // there is no day-period grouping (the es 12-hour band machinery has no fr
514
+ // analog and is dropped).
515
+ function hourSpanList(fires: number[], opts: Opts): string {
516
+ return 'de ' + joinList(fires.map(function each(hour) {
517
+ return clockNumeric(hour, 0, null, opts);
518
+ }));
519
+ }
520
+
521
+ // A repeating minute step, qualified by the active hour window(s).
522
+ function renderMinuteFrequency(
523
+ schedule: Schedule,
524
+ plan: Extract<PlanNode, {kind: 'minuteFrequency'}>,
525
+ opts: Opts
526
+ ): string {
527
+ let phrase = stepCycle60(stepSegment(schedule, 'minute'), 'minute',
528
+ 'heure', opts);
529
+
530
+ if (plan.hours.kind === 'during') {
531
+ // A uneven hour stride confines the minute cadence to its own bounded hour
532
+ // cadence ("toutes les 15 minutes, toutes les cinq heures de minuit à
533
+ // 20 h").
534
+ const cadence = unevenHourCadence(schedule, opts);
535
+
536
+ if (cadence) {
537
+ phrase += ', ' + cadence;
538
+ }
539
+ else {
540
+ // An offset step (e.g. 1/2) arrives here; a single step reads as a
541
+ // confinement, not the verbose window list.
542
+ phrase += singleHourStep(schedule.analyses.segments.hour) ?
543
+ ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts) :
544
+ ' ' + hourSpanFromTimes(schedule, plan.hours.times, opts);
545
+ }
546
+ }
547
+ else if (plan.hours.kind === 'window') {
548
+ phrase += ' ' + hourWindow(plan.hours, opts);
549
+ }
550
+ else if (plan.hours.kind === 'step') {
551
+ // A clean stride is a confinement ("les heures paires", or the active-hour
552
+ // list), never a juxtaposed cadence ("toutes les deux heures").
553
+ phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
554
+ }
555
+
556
+ return phrase + trailingQualifier(schedule, opts);
557
+ }
558
+
559
+ // "chaque minute de l'heure de 9 h". A wildcard minute is the whole hour, so it
560
+ // reads as that hour itself rather than a synthesized "de 9 h à 9 h 59" range
561
+ // the source never stated; a plain range is a real window and keeps "de … à …".
562
+ function renderMinuteSpanInHour(
563
+ schedule: Schedule,
564
+ plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
565
+ opts: Opts
566
+ ): string {
567
+ if (schedule.pattern.minute === '*') {
568
+ return 'chaque minute de l\'heure ' +
569
+ fromTime(timePhrase(plan.hour, 0, null, opts)) +
570
+ trailingQualifier(schedule, opts);
571
+ }
572
+
573
+ return 'chaque minute ' +
574
+ timeRange({hour: plan.hour, minute: plan.span[0]},
575
+ {hour: plan.hour, minute: plan.span[1]}, opts) +
576
+ trailingQualifier(schedule, opts);
577
+ }
578
+
579
+ // A minute window under discrete hours. French re-plans the wildcard form:
580
+ // rather than "during the X hours", each hour reads as its own window ("de 9 h
581
+ // à 9 h 59").
582
+ function renderMinutesAcrossHours(
583
+ schedule: Schedule,
584
+ plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
585
+ opts: Opts
586
+ ): string {
587
+ // A uneven hour stride reads as a cadence, not a wall of hour columns: the
588
+ // minute lead, then "toutes les N heures de X à Y".
589
+ const cadence = unevenHourCadence(schedule, opts);
590
+
591
+ if (plan.form === 'wildcard') {
592
+ if (cadence !== null) {
593
+ return 'chaque minute, ' + cadence + trailingQualifier(schedule, opts);
594
+ }
595
+
596
+ if (singleHourStep(schedule.analyses.segments.hour)) {
597
+ return 'chaque minute, ' +
598
+ stepHourSpan(stepSegment(schedule, 'hour'), opts) +
599
+ trailingQualifier(schedule, opts);
600
+ }
601
+
602
+ return 'chaque minute ' + hourSpanFromTimes(schedule, plan.times, opts) +
603
+ trailingQualifier(schedule, opts);
604
+ }
605
+
606
+ const lead = plan.form === 'range' ?
607
+ minuteRangeLead(schedule.pattern.minute) :
608
+ minutesList(schedule, opts);
609
+
610
+ if (cadence !== null) {
611
+ return lead + ', ' + cadence + trailingQualifier(schedule, opts);
612
+ }
613
+
614
+ return lead + ', ' + atHourTimes(schedule, plan.times, opts) +
615
+ trailingQualifier(schedule, opts);
616
+ }
617
+
618
+ function renderMinuteSpanAcrossHourStep(
619
+ schedule: Schedule,
620
+ plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>,
621
+ opts: Opts
622
+ ): string {
623
+ const segment = stepSegment(schedule, 'hour');
624
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence; an
625
+ // offset-clean step keeps its confinement / per-step phrasing.
626
+ const cadence = unevenHourCadence(schedule, opts);
627
+
628
+ // A wildcard minute (a cadence) is reached only for a clean stride (a bounded
629
+ // or uneven step routes through minutesAcrossHours instead) and is confined.
630
+ if (plan.form === 'wildcard') {
631
+ return 'chaque minute, ' + stepHourSpan(segment, opts) +
632
+ trailingQualifier(schedule, opts);
633
+ }
634
+
635
+ // A minute list keeps the same cadence clause as the range; only its lead
636
+ // differs ("aux minutes 5 et 30 de chaque heure" vs "chaque minute de 0 à
637
+ // 30").
638
+ const lead = plan.form === 'list' ?
639
+ minutesList(schedule, opts) :
640
+ minuteRangeLead(schedule.pattern.minute);
641
+
642
+ return lead + ', ' +
643
+ (cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
644
+ }
645
+
646
+ // --- Hour renderers. ---
647
+
648
+ function renderEveryHour(
649
+ schedule: Schedule,
650
+ plan: Extract<PlanNode, {kind: 'everyHour'}>,
651
+ opts: Opts
652
+ ): string {
653
+ return 'chaque heure' + trailingQualifier(schedule, opts);
654
+ }
655
+
656
+ function renderHourRange(
657
+ schedule: Schedule,
658
+ plan: Extract<PlanNode, {kind: 'hourRange'}>,
659
+ opts: Opts
660
+ ): string {
661
+ const window = hourWindow(boundedWindow(plan), opts);
662
+
663
+ if (plan.minuteForm === 'wildcard') {
664
+ return 'chaque minute ' + window + trailingQualifier(schedule, opts);
665
+ }
666
+
667
+ if (plan.minuteForm === 'range') {
668
+ return minuteRangeLead(schedule.pattern.minute) + ', ' + window +
669
+ trailingQualifier(schedule, opts);
670
+ }
671
+
672
+ // On the hour the window joins directly ("chaque heure de 9 h à 17 h"); a
673
+ // discrete minute anchors its own clause first.
674
+ if (schedule.pattern.minute === '0') {
675
+ return 'chaque heure ' + window + trailingQualifier(schedule, opts);
676
+ }
677
+
678
+ const lead = schedule.shapes.minute === 'single' ?
679
+ 'à la minute ' + schedule.pattern.minute + ' de chaque heure' :
680
+ minutesList(schedule, opts);
681
+
682
+ return lead + ', ' + window + trailingQualifier(schedule, opts);
683
+ }
684
+
685
+ function renderHourStep(
686
+ schedule: Schedule,
687
+ plan: Extract<PlanNode, {kind: 'hourStep'}>,
688
+ opts: Opts
689
+ ): string {
690
+ // A bounded or uneven hour step reads as its endpoint-pinning cadence
691
+ // ("toutes les deux heures de 9 h à 17 h"); an offset-clean step keeps its
692
+ // bare or "à partir de" cadence.
693
+ const cadence = unevenHourCadence(schedule, opts);
694
+
695
+ if (cadence !== null) {
696
+ return cadence + trailingQualifier(schedule, opts);
697
+ }
698
+
699
+ return stepHours(stepSegment(schedule, 'hour'), opts) +
700
+ trailingQualifier(schedule, opts);
701
+ }
702
+
703
+ // The hour-range plan as a window. The close lands on the top of the final
704
+ // hour (minute 0) unless the minute genuinely runs to the end of that hour —
705
+ // i.e. a wildcard minute, which fills every minute and states no separate
706
+ // clause. A pinned/listed/ranged minute is named in its own lead clause, so
707
+ // folding it into the close too would read as a span ("à 17 h 5") that
708
+ // contradicts the minute clause; the window stays bare ("à 17 h").
709
+ function boundedWindow(
710
+ plan: Extract<PlanNode, {kind: 'hourRange'}>
711
+ ): {from: number; to: number; last: number} {
712
+ const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
713
+
714
+ return {from: plan.from, last, to: plan.to};
715
+ }
716
+
717
+ // "de 9 h à 17 h 45": a window from the top of the first hour to the minute
718
+ // field's last fire within the final hour.
719
+ function hourWindow(
720
+ window: {from: number; to: number; last: number},
721
+ opts: Opts
722
+ ): string {
723
+ return timeRange({hour: window.from, minute: 0},
724
+ {hour: window.to, minute: window.last}, opts);
725
+ }
726
+
727
+ // Whether BOTH the date and weekday fields are restricted (not '*'): cron
728
+ // fires when either condition matches, making this a date-OR-weekday union.
729
+ function isDateWeekdayUnion(schedule: Schedule): boolean {
730
+ return schedule.pattern.date !== '*' && schedule.pattern.weekday !== '*';
731
+ }
732
+
733
+ // The month lead for the unified union frame, with a trailing comma appended
734
+ // when the lead is a heavy enumeration (≥2 non-range months).
735
+ // Single month → `en janvier`; range → `de janvier à mars`;
736
+ // step/enumeration (≥2 flattened singles) → `en janvier, …, et novembre,`.
737
+ // Wildcard month → '' (omit; frame starts with the time).
738
+ function unionMonthLeadFull(schedule: Schedule): string {
739
+ if (schedule.pattern.month === '*') {
740
+ return '';
741
+ }
742
+
743
+ const lead = monthPhrase(schedule, monthRanged(schedule) ? 'de ' : 'en ');
744
+ const segments = flattenSteps(segmentsOf(schedule, 'month'));
745
+ const isEnumeration = !monthRanged(schedule) && segments.length >= 2;
746
+
747
+ return isEnumeration ? lead + ',' : lead;
748
+ }
749
+
750
+ // The DOM arm for the union frame — month-less, driven by the date shape.
751
+ // Quartz and open-step forms are self-contained; ranges use `du N au M`;
752
+ // a single date reads `le N` under a restricted month (month is in the lead) or
753
+ // `le N de chaque mois` under a wildcard month. The 1st is the ordinal "1er".
754
+ function domArm(schedule: Schedule, opts: Opts): string {
755
+ const date = schedule.pattern.date;
756
+ const quartz = quartzDatePhrase(date);
757
+
758
+ if (quartz) {
759
+ return quartz;
760
+ }
761
+
762
+ // In the union the `*/2` day-of-month is a parity predicate over the days of
763
+ // the month ("un jour impair du mois" = 1, 3, …, 31, resetting each month),
764
+ // not the durative "tous les deux jours du mois" the standalone form uses. A
765
+ // bare "tous les deux jours" would mis-imply a continuous every-other-day
766
+ // cadence with no monthly anchor, so the reader could not reconstruct the
767
+ // odd days.
768
+ const parity = parityDayPredicate(date);
769
+
770
+ if (parity) {
771
+ return parity;
772
+ }
773
+
774
+ if (isOpenStep(date)) {
775
+ return stepDates(date, opts);
776
+ }
777
+
778
+ const segments = segmentsOf(schedule, 'date');
779
+
780
+ if (segments.length === 1 && segments[0].kind === 'range') {
781
+ return 'du ' + dayOrdinal(segments[0].bounds[0]) + ' au ' +
782
+ segments[0].bounds[1] + ' du mois';
783
+ }
784
+
785
+ if (segments.length === 1 && segments[0].kind === 'single') {
786
+ return schedule.pattern.month === '*' ?
787
+ 'le ' + dayOrdinal(segments[0].value) + ' de chaque mois' :
788
+ 'le ' + dayOrdinal(segments[0].value);
789
+ }
790
+
791
+ return joinList(dateWords(segments)) + ' du mois';
792
+ }
793
+
794
+ // The DOW arm for the union frame — month-less, driven by the weekday shape.
795
+ // Quartz forms are self-contained; a single weekday reads `n'importe quel
796
+ // <name>`; all other forms use the same phrasing as the standalone weekday
797
+ // qualifier (range → `n'importe quel jour du lundi au vendredi`; list/step →
798
+ // `le mardi, le jeudi, …`).
799
+ function dowArm(schedule: Schedule): string {
800
+ const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
801
+
802
+ if (quartz) {
803
+ return quartz;
804
+ }
805
+
806
+ // Weekday lists display Monday-first (Sunday last); a lone range keeps its
807
+ // form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
808
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
809
+ const allSingles = segments.every(function single(segment) {
810
+ return segment.kind === 'single';
811
+ });
812
+
813
+ if (allSingles && segments.length === 1) {
814
+ return 'n\'importe quel ' +
815
+ weekdayName((segments[0] as SingleNameSegment).value);
816
+ }
817
+
818
+ if (allSingles) {
819
+ return recurringWeekdayList(segments as SingleNameSegment[]);
820
+ }
821
+
822
+ // A lone weekday range reads "n'importe quel jour du lundi au vendredi" in
823
+ // the union: the leading "n'importe quel jour" makes it a day predicate
824
+ // parallel to the date arm ("le 1er de chaque mois, soit … soit n'importe
825
+ // quel jour du lundi au vendredi"), so the union plainly joins two
826
+ // independent day conditions.
827
+ if (segments.length === 1) {
828
+ return 'n\'importe quel jour ' +
829
+ weekdayRange(segments[0] as RangeNameSegment);
830
+ }
831
+
832
+ return mixedWeekdayList(segments);
833
+ }
834
+
835
+ // The `, soit <DOM> soit <DOW>` correlative suffix for the union frame.
836
+ function unionSoitSuffix(schedule: Schedule, opts: Opts): string {
837
+ return ', soit ' + domArm(schedule, opts) + ', soit ' + dowArm(schedule);
838
+ }
839
+
840
+ // "tous les jours à 9 h 30 et 17 h".
841
+ function renderClockTimes(
842
+ schedule: Schedule,
843
+ plan: Extract<PlanNode, {kind: 'clockTimes'}>,
844
+ opts: Opts
845
+ ): string {
846
+ // An hour step or range (or arithmetic-progression hour list) under a single
847
+ // pinned minute reads as a cadence or window rather than a cross-product of
848
+ // clock times.
849
+ if (schedule.shapes.minute === 'single') {
850
+ const minute = +schedule.pattern.minute;
851
+ const cadence = hourCadence(schedule, minute, opts) ??
852
+ hourRangeCadence(schedule, minute, opts);
853
+
854
+ if (cadence !== null) {
855
+ return cadence;
856
+ }
857
+ }
858
+
859
+ const phrases = plan.times.map(function clock(time) {
860
+ return atTime(timePhrase(time.hour, time.minute, time.second, opts));
861
+ });
862
+
863
+ return leadingQualifier(schedule, opts) + groupClockTimes(phrases);
864
+ }
865
+
866
+ // The genitive clock-time list for a minute-0 compose-seconds confinement: each
867
+ // time with its minute forced visible ("0 h"), grouped as usual, then reframed
868
+ // from "à …" to the genitive "de …" the caller prepends. So a pinned minute-0
869
+ // reads "de 0 h", never silently dropped.
870
+ function explicitClockList(
871
+ times: {hour: number; minute: number; second?: number | null}[],
872
+ opts: Opts
873
+ ): string {
874
+ const phrases = times.map(function clock(time) {
875
+ return atTime(explicitTimePhrase(time.hour, time.minute, opts));
876
+ });
877
+ const grouped = groupClockTimes(phrases);
878
+
879
+ // Strip the leading "à " so the caller's "de " produces the genitive form.
880
+ return grouped.startsWith('à ') ? grouped.slice(2) : grouped;
881
+ }
882
+
883
+ // The bare-hour list for a minute-0 duration confinement, keeping the "à …"
884
+ // frame the caller embeds after "pendant une minute": "à 9 h", "à minuit",
885
+ // "à 9 h et 11 h". The hour reads as a bare hour (no minutes), since the
886
+ // "pendant une minute" frame already carries the one-minute window.
887
+ function durationHourList(
888
+ times: {hour: number; minute: number; second?: number | null}[],
889
+ opts: Opts
890
+ ): string {
891
+ const phrases = times.map(function clock(time) {
892
+ return atTime(barePoint(time.hour, opts));
893
+ });
894
+
895
+ return groupClockTimes(phrases);
896
+ }
897
+
898
+ // A clock time with its minute forced visible and the noon/midnight words
899
+ // suppressed: "0 h", "9 h" stays "9 h" but a top-of-hour 0/12 reads numerically
900
+ // ("0 h", "12 h"). So a pinned minute-0 confinement always shows its hour as a
901
+ // numeral, never minuit/midi (which would read as the exact instant, not the
902
+ // one-minute :00 window).
903
+ function explicitTimePhrase(hour: number, minute: number, opts: Opts): string {
904
+ return clockNumeric(hour, minute, null, opts);
905
+ }
906
+
907
+ // Group a chronological run of "à …" clock phrases. fr is 24h-only (no day
908
+ // periods), so every phrase is the same "à" form; grouping shares the "à"
909
+ // once.
910
+ function groupClockTimes(phrases: string[]): string {
911
+ if (phrases.length < 2) {
912
+ return joinList(phrases);
913
+ }
914
+
915
+ return groupClockTimesByArticle(phrases);
916
+ }
917
+
918
+ // Group clock-time phrases under one shared "à" (24-hour clock): every fr clock
919
+ // time takes the same bare "à", so the prefix is factored once over the list
920
+ // ("à 9 h, 17 h et 22 h"). In a multi-time list noon reads numerically as
921
+ // "12 h" — the bare-noun "midi" is reserved for a SINGLE clock time (notes.md:
922
+ // "midi" the exact point, "12 h" alongside other numeric hours); midnight keeps
923
+ // "minuit" even in a list (panel-ratified). The one-o'clock fire is fronted,
924
+ // mirroring the donor's singular-article ("a la 1") grouping that runs ahead of
925
+ // the plural group — fr has no la/las split, so the fronting is the only trace
926
+ // of it, but it keeps the list order identical to the reviewed oracle. A
927
+ // non-"à" phrase (none in fr) falls back to a plain list.
928
+ function groupClockTimesByArticle(phrases: string[]): string {
929
+ const prefix = 'à ';
930
+ const oneOclock: string[] = [];
931
+ const rest: string[] = [];
932
+
933
+ for (const phrase of phrases) {
934
+ if (!phrase.startsWith(prefix)) {
935
+ return joinList(phrases);
936
+ }
937
+
938
+ const raw = phrase.slice(prefix.length);
939
+ const value = phrases.length > 1 && raw === 'midi' ? '12 h' : raw;
940
+
941
+ // The one-o'clock fire ("1 h", "1 h 30", "1 h 5"), distinguished by a lone
942
+ // leading "1" before the "h"; "12 h"/"13 h" do not match.
943
+ if ((/^1( |h)/u).test(value)) {
944
+ oneOclock.push(value);
945
+ }
946
+ else {
947
+ rest.push(value);
948
+ }
949
+ }
950
+
951
+ return prefix + joinList([...oneOclock, ...rest]);
952
+ }
953
+
954
+ // Compact form past the enumeration cap: a single minute folds into per-segment
955
+ // hour windows; a minute list leads with its own clause.
956
+ function renderCompactClockTimes(
957
+ schedule: Schedule,
958
+ plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
959
+ opts: Opts
960
+ ): string {
961
+ if (plan.fold) {
962
+ // An hour step or range (or arithmetic-progression hour list) under the
963
+ // single pinned minute reads as a cadence or window, not a wall of clock
964
+ // times. (Returns null for an irregular list, which keeps folding below.)
965
+ const cadence = hourCadence(schedule, plan.minute, opts) ??
966
+ hourRangeCadence(schedule, plan.minute, opts);
967
+
968
+ if (cadence !== null) {
969
+ return cadence;
970
+ }
971
+
972
+ const ranged = segmentsOf(schedule, 'hour').some(function range(segment) {
973
+ return segment.kind === 'range';
974
+ });
975
+
976
+ // A folded contiguous hour range reads with the hourly cadence ("chaque
977
+ // heure de 9 h 30 à 20 h 30 et aussi à 22 h 30"), not "tous les jours".
978
+ if (ranged && !schedule.analyses.clockSecond) {
979
+ return 'chaque heure ' +
980
+ hourSegmentTimes(
981
+ schedule, plan.minute, schedule.analyses.clockSecond, opts
982
+ ) +
983
+ trailingQualifier(schedule, opts);
984
+ }
985
+
986
+ return leadingQualifier(schedule, opts) +
987
+ hourSegmentTimes(
988
+ schedule, plan.minute, schedule.analyses.clockSecond, opts
989
+ );
990
+ }
991
+
992
+ // A uneven hour stride reads as a cadence after the minute lead, not a wall
993
+ // of clock-time columns.
994
+ const cadence = unevenHourCadence(schedule, opts);
995
+ const phrase = cadence ?
996
+ minutesList(schedule, opts) + ', ' + cadence +
997
+ trailingQualifier(schedule, opts) :
998
+ minutesList(schedule, opts) + ', ' +
999
+ hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
1000
+
1001
+ return schedule.analyses.clockSecond ?
1002
+ secondsLeadClause(schedule, opts) + ', ' + phrase :
1003
+ phrase;
1004
+ }
1005
+
1006
+ // The plan dispatch table.
1007
+ const renderers = {
1008
+ clockTimes: renderClockTimes,
1009
+ compactClockTimes: renderCompactClockTimes,
1010
+ composeSeconds: renderComposeSeconds,
1011
+ everyHour: renderEveryHour,
1012
+ everyMinute: renderEveryMinute,
1013
+ everySecond: renderEverySecond,
1014
+ hourRange: renderHourRange,
1015
+ hourStep: renderHourStep,
1016
+ minuteFrequency: renderMinuteFrequency,
1017
+ minuteSpanAcrossHourStep: renderMinuteSpanAcrossHourStep,
1018
+ minuteSpanInHour: renderMinuteSpanInHour,
1019
+ minutesAcrossHours: renderMinutesAcrossHours,
1020
+ multipleMinutes: renderMultipleMinutes,
1021
+ rangeOfMinutes: renderRangeOfMinutes,
1022
+ secondPastMinute: renderSecondPastMinute,
1023
+ secondsWithinMinute: renderSecondsWithinMinute,
1024
+ singleMinute: renderSingleMinute,
1025
+ standaloneSeconds: renderStandaloneSeconds
1026
+ };
1027
+
1028
+ // --- Step phrases. ---
1029
+
1030
+ // Speak a step cadence over a `cycle`-long field (60 for minute/second). A
1031
+ // clean stride from the top of the cycle is the bare cadence ("toutes les
1032
+ // 15 minutes"); a uniform offset (start within the first interval, the interval
1033
+ // still dividing the cycle) names only its start, since it wraps cleanly with
1034
+ // no distinct endpoint ("toutes les six minutes à partir de la minute 5 de
1035
+ // chaque heure"); a non-uniform stride (start >= interval, or an interval that
1036
+ // does not divide the cycle) pins both endpoints so the bounded, non-wrapping
1037
+ // set reads unambiguously ("toutes les deux minutes de la minute 3 à 59 de
1038
+ // chaque heure"). This is the one phrasing for every step the renderer speaks.
1039
+ function renderStride(stride: Stride, opts: Opts): string {
1040
+ const {interval, start, last, cycle, unit, anchor} = stride;
1041
+ const cadence = 'toutes les ' + numero(interval, opts) + ' ' + unit + 's';
1042
+
1043
+ // A context that supplies its own trailing scope passes an empty anchor, so
1044
+ // the cadence keeps its endpoints but drops the "de chaque <anchor>" tail.
1045
+ const tail = anchor ? ' de chaque ' + anchor : '';
1046
+
1047
+ return chooseStride({start, interval, last, cycle}, {
1048
+ bare: () => cadence,
1049
+ offset: () => cadence + ' à partir de la ' + unit + ' ' + start + tail,
1050
+ bounded: () =>
1051
+ cadence + ' de la ' + unit + ' ' + start + ' à ' + last + tail
1052
+ });
1053
+ }
1054
+
1055
+ // "toutes les 15 minutes", "aux minutes 5, 20 et 35 de chaque heure", or
1056
+ // "toutes les 15 minutes à partir de la minute 5 de chaque heure". A step shape
1057
+ // only reaches here as a clean cadence (the interval divides 60), so the stride
1058
+ // collapses to the bare or uniform-offset form; an offset/uneven set arrives as
1059
+ // a fire list and is recognized by the list path instead.
1060
+ function stepCycle60(
1061
+ segment: StepSegment,
1062
+ unit: string,
1063
+ anchor: string,
1064
+ opts: Opts
1065
+ ): string {
1066
+ if (segment.startToken.indexOf('-') !== -1) {
1067
+ return 'aux ' + unit + 's ' + joinList(wordList(segment.fires)) +
1068
+ ' de chaque ' + anchor;
1069
+ }
1070
+
1071
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
1072
+
1073
+ // A short offset cadence still lists its fires; the stride phrasing names the
1074
+ // interval and offset only once there are enough fires to beat the list.
1075
+ if (start !== 0 && segment.fires.length <= 3) {
1076
+ return 'aux ' + unit + 's ' + joinList(wordList(segment.fires)) +
1077
+ ' de chaque ' + anchor;
1078
+ }
1079
+
1080
+ return renderStride({
1081
+ interval: segment.interval,
1082
+ start,
1083
+ last: segment.fires[segment.fires.length - 1],
1084
+ cycle: 60,
1085
+ unit,
1086
+ anchor
1087
+ }, opts);
1088
+ }
1089
+
1090
+ // Speak a minute/second field's enumerated fires as a step cadence when they
1091
+ // form an arithmetic progression long enough to beat the list. Returns null for
1092
+ // a non-progression or a too-short list, leaving the caller to enumerate.
1093
+ function strideFromSegments(
1094
+ segments: Segment[],
1095
+ unit: string,
1096
+ anchor: string,
1097
+ opts: Opts
1098
+ ): string | null {
1099
+ const values = singleValues(segments);
1100
+ const step = values && arithmeticStep(values);
1101
+
1102
+ return step ?
1103
+ renderStride({...step, cycle: 60, unit, anchor}, opts) :
1104
+ null;
1105
+ }
1106
+
1107
+
1108
+ // "toutes les six heures", "à 9 h, 11 h et 1 h", or "toutes les cinq heures à
1109
+ // partir de 2 h".
1110
+ function stepHours(segment: StepSegment, opts: Opts): string {
1111
+ if (segment.startToken.indexOf('-') !== -1) {
1112
+ return groupClockTimesByArticle(atTimes(segment.fires, opts));
1113
+ }
1114
+
1115
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
1116
+ const interval = segment.interval;
1117
+
1118
+ // A clean stride from midnight is the bare cadence. (An uneven stride is
1119
+ // rewritten to its fires upstream and never reaches here.)
1120
+ if (start === 0) {
1121
+ return 'toutes les ' + numero(interval, opts) + ' heures';
1122
+ }
1123
+
1124
+ if (segment.fires.length <= 3) {
1125
+ return groupClockTimesByArticle(atTimes(segment.fires, opts));
1126
+ }
1127
+
1128
+ return 'toutes les ' + numero(interval, opts) + ' heures à partir de ' +
1129
+ timePhrase(start, 0, null, opts);
1130
+ }
1131
+
1132
+ // --- Hour-step cadence (the 24-cycle analog of renderStride). ---
1133
+
1134
+ // Speak an hour stride as a cadence with clock-time bounds: a clean stride from
1135
+ // midnight is the bare cadence ("toutes les deux heures"); a clean offset names
1136
+ // only its start ("toutes les six heures à partir de 2 h"); a bounded or
1137
+ // non-tiling stride pins both clock-time endpoints ("toutes les deux heures de
1138
+ // 9 h à 17 h") so the bounded set reads unambiguously. Used wherever an hour
1139
+ // step (or arithmetic-progression hour list) would otherwise be
1140
+ // cross-multiplied into a wall of clock times.
1141
+ function hourStrideCadence(
1142
+ stride: {start: number; interval: number; last: number},
1143
+ opts: Opts
1144
+ ): string {
1145
+ const {start, interval, last} = stride;
1146
+ const cadence = 'toutes les ' + numero(interval, opts) + ' heures';
1147
+
1148
+ return chooseStride({start, interval, last, cycle: 24}, {
1149
+ bare: () => cadence,
1150
+ offset: () => cadence + ' à partir de ' + timePhrase(start, 0, null, opts),
1151
+ bounded: () => cadence + ' ' + fromTime(timePhrase(start, 0, null, opts)) +
1152
+ ' ' + toTime(timePhrase(last, 0, null, opts))
1153
+ });
1154
+ }
1155
+
1156
+ // The bounded cadence for an hour stride that pins both clock-time endpoints,
1157
+ // or null when the hour is not such a stride. An offset-clean stride keeps its
1158
+ // existing confinement form, so only the endpoint-bearing case routes here.
1159
+ function unevenHourCadence(schedule: Schedule, opts: Opts): string | null {
1160
+ const stride = hourStride(schedule);
1161
+
1162
+ if (!stride || offsetCleanStride(stride)) {
1163
+ return null;
1164
+ }
1165
+
1166
+ return hourStrideCadence(stride, opts);
1167
+ }
1168
+
1169
+ // The hour field's stride, or null when the hour is not a cadence: a step
1170
+ // segment yields its {start, interval, last} directly; an all-single hour list
1171
+ // yields one only when its values form a step progression (so an irregular list
1172
+ // like 9,17 keeps enumerating).
1173
+ function hourStride(
1174
+ schedule: Schedule
1175
+ ): {start: number; interval: number; last: number} | null {
1176
+ const segments = segmentsOf(schedule, 'hour');
1177
+
1178
+ if (segments.length === 1 && segments[0].kind === 'step') {
1179
+ const segment = segments[0];
1180
+
1181
+ // A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
1182
+ // value, not a stride: it has no interval to speak and no endpoint to pin.
1183
+ if (segment.fires.length < 2) {
1184
+ return null;
1185
+ }
1186
+
1187
+ const start = segment.startToken === '*' ?
1188
+ 0 :
1189
+ +segment.startToken.split('-')[0];
1190
+
1191
+ return {interval: segment.interval, last: segment.fires[
1192
+ segment.fires.length - 1], start};
1193
+ }
1194
+
1195
+ const values = singleValues(segments);
1196
+
1197
+ return values && hourListStride(values);
1198
+ }
1199
+
1200
+ // The second's status against a pinned minute: a wildcard or sub-minute step
1201
+ // fills the minute (a "pendant une minute" frame at minute 0); a single 0 is
1202
+ // just the top of the minute (no clause); anything else needs its own clause.
1203
+ function subMinuteSecond(schedule: Schedule): boolean {
1204
+ return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
1205
+ }
1206
+
1207
+ // The lead clause for an hour-cadence rendering: the second and the pinned
1208
+ // minute, before the hour cadence. A pinned minute 0 folds in — a single, list,
1209
+ // or range second is counted "de chaque heure" (the minute-0 is the top of the
1210
+ // hour), and a wildcard or sub-minute step second takes a "pendant une minute"
1211
+ // frame (the whole minute-0 window). A non-zero minute is a real clock minute:
1212
+ // the second leads with its own clause (if any), then the minute reads "à la
1213
+ // minute M".
1214
+ function hourCadenceLead(
1215
+ schedule: Schedule, minute: number, opts: Opts
1216
+ ): string {
1217
+ if (minute === 0) {
1218
+ if (subMinuteSecond(schedule)) {
1219
+ return secondsClause(schedule, 'minute', opts) + ' pendant une minute';
1220
+ }
1221
+
1222
+ return secondsClause(schedule, 'heure', opts);
1223
+ }
1224
+
1225
+ const minutePhrase = 'à la minute ' + minute;
1226
+
1227
+ // A single 0 second is just the top of the minute, so the minute leads alone;
1228
+ // any other second prefixes its own clause.
1229
+ if (schedule.pattern.second === '0') {
1230
+ return minutePhrase;
1231
+ }
1232
+
1233
+ return secondsClause(schedule, 'minute', opts) + ', ' + minutePhrase;
1234
+ }
1235
+
1236
+ // Render an hour step (or arithmetic-progression hour list) under a single
1237
+ // pinned minute and a second as a cadence — the lead clause, then the hour
1238
+ // cadence — instead of cross-multiplying the hours into a wall of clock times.
1239
+ // Returns null when the hour is not a stride, or when the cross-product is
1240
+ // short enough that enumeration is no longer than the cadence. Renderer-only;
1241
+ // the Schedule is unchanged.
1242
+ function hourCadence(
1243
+ schedule: Schedule, minute: number, opts: Opts
1244
+ ): string | null {
1245
+ const stride = hourStride(schedule);
1246
+
1247
+ if (!stride) {
1248
+ return null;
1249
+ }
1250
+
1251
+ const fires = (stride.last - stride.start) / stride.interval + 1;
1252
+
1253
+ // A short stride that spells out as few clock times stays an enumeration only
1254
+ // when it wraps cleanly (an offset-clean stride with no endpoint): the bare
1255
+ // or "à partir de" form is no shorter than the list. A bounded or uneven
1256
+ // stride has no clean wrap, so its endpoint-pinning cadence ("toutes les cinq
1257
+ // heures de minuit à 20 h") reads better however short.
1258
+ if (schedule.pattern.second === '0' && fires <= maxClockTimes &&
1259
+ offsetCleanStride(stride)) {
1260
+ return null;
1261
+ }
1262
+
1263
+ // A wildcard or sub-minute step second confined to minute 0 of a clean hour
1264
+ // stride is a confinement, not a juxtaposed cadence: it reads "pendant une
1265
+ // minute, pendant les heures paires", reusing the hour-step confinement idiom
1266
+ // so the minute-0 window is never heard as the bare hour cadence.
1267
+ const confinement = minute === 0 && subMinuteSecond(schedule) &&
1268
+ cleanStrideSegment(schedule);
1269
+
1270
+ if (confinement) {
1271
+ return secondsClause(schedule, 'minute', opts) + ' pendant une minute, ' +
1272
+ stepHourSpan(confinement, opts) + trailingQualifier(schedule, opts);
1273
+ }
1274
+
1275
+ // A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
1276
+ // lead clause to fold in, so the bounded cadence stands on its own ("toutes
1277
+ // les cinq heures de minuit à 20 h").
1278
+ if (minute === 0 && schedule.pattern.second === '0') {
1279
+ return hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1280
+ }
1281
+
1282
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1283
+ hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
1284
+ }
1285
+
1286
+ // The hour step segment when the hour is a clean stride fr renders as a
1287
+ // confinement phrase ("pendant les heures paires"); null otherwise (an offset
1288
+ // or bounded step, an uneven stride, or an arithmetic-progression list, which
1289
+ // keep the bounded cadence form).
1290
+ function cleanStrideSegment(schedule: Schedule): StepSegment | null {
1291
+ const segments = segmentsOf(schedule, 'hour');
1292
+ const segment = segments.length === 1 && segments[0];
1293
+
1294
+ if (!segment || segment.kind !== 'step' ||
1295
+ segment.startToken.indexOf('-') !== -1) {
1296
+ return null;
1297
+ }
1298
+
1299
+ return segment;
1300
+ }
1301
+
1302
+ // Whether the hour field is a range — or a list whose segments include a
1303
+ // range — and so forms a window rather than a cross-product of clock times.
1304
+ function hasHourWindow(schedule: Schedule): boolean {
1305
+ return segmentsOf(schedule, 'hour').some(function range(segment) {
1306
+ return segment.kind === 'range';
1307
+ });
1308
+ }
1309
+
1310
+ // Render an hour range (or a list whose segments include a range) under minute
1311
+ // 0 and a meaningful second as the hour-range window — the lead clause, then
1312
+ // "de 9 h à 17 h" (and any non-contiguous hour joined with "et aussi") —
1313
+ // instead of cross-multiplying the hours into a wall of clock times. The
1314
+ // hour-RANGE analog of hourCadence. Returns null when the hour has no range,
1315
+ // when the minute is non-zero, or when a plain :00 set carries no clause.
1316
+ function hourRangeCadence(
1317
+ schedule: Schedule, minute: number, opts: Opts
1318
+ ): string | null {
1319
+ if (minute !== 0 || !hasHourWindow(schedule) ||
1320
+ schedule.pattern.second === '0') {
1321
+ return null;
1322
+ }
1323
+
1324
+ // A wildcard or sub-minute step second confined to minute 0 is the whole
1325
+ // minute-0 window ("pendant une minute"), confined to the hour range with the
1326
+ // "pendant les heures …" idiom — kept distinct from the bare minute-0 window
1327
+ // ("chaque heure de 9 h à 17 h") so the confinement is never heard as it.
1328
+ if (subMinuteSecond(schedule)) {
1329
+ return secondsClause(schedule, 'minute', opts) + ' pendant une minute, ' +
1330
+ 'pendant les heures ' + hourSegmentTimes(schedule, 0, null, opts) +
1331
+ trailingQualifier(schedule, opts);
1332
+ }
1333
+
1334
+ return hourCadenceLead(schedule, minute, opts) + ', ' +
1335
+ hourSegmentTimes(schedule, 0, null, opts) +
1336
+ trailingQualifier(schedule, opts);
1337
+ }
1338
+
1339
+ // --- Hour-time phrasing. ---
1340
+
1341
+ // The fixed hour(s) of a stepped/listed minute, named as the HOUR rather than a
1342
+ // "à HH h" clock instant the minute never fires at: noon and midnight read as
1343
+ // the hour word ("à midi"/"à minuit"), any other hour as the whole hour "de
1344
+ // l'heure de HH h" (the idiom a wildcard minute already uses). Used by the
1345
+ // compact-clock non-fold path, where the minute is a step or list.
1346
+ function hourContextTimes(schedule: Schedule, opts: Opts): string {
1347
+ const segments = segmentsOf(schedule, 'hour');
1348
+
1349
+ // Collect the point hours (singles and step fires) — a range stays a window.
1350
+ const points: number[] = [];
1351
+ const hasRange = segments.some(function range(segment) {
1352
+ return segment.kind === 'range';
1353
+ });
1354
+
1355
+ segments.forEach(function collect(segment) {
1356
+ if (segment.kind === 'step') {
1357
+ points.push(...segment.fires);
1358
+ }
1359
+ else if (segment.kind === 'single') {
1360
+ points.push(+segment.value);
1361
+ }
1362
+ });
1363
+
1364
+ // All point hours, all noon/midnight: stand alone as their own words ("à
1365
+ // minuit et à midi").
1366
+ function isWord(hour: number): boolean {
1367
+ return hour === 0 || hour === 12;
1368
+ }
1369
+
1370
+ if (!hasRange && points.every(isWord)) {
1371
+ return joinList(points.map(function each(hour) {
1372
+ return atTime(barePoint(hour, opts));
1373
+ }));
1374
+ }
1375
+
1376
+ // A point hour as the whole hour: "de l'heure de HH h".
1377
+ function wholeHour(hour: number): string {
1378
+ return 'de l\'heure ' + fromTime(barePoint(hour, opts));
1379
+ }
1380
+
1381
+ // Otherwise each whole hour reads as a window ("de HH h à HH h" for a range,
1382
+ // "de l'heure de HH h" for a point), never a false "à HH h" clock instant the
1383
+ // stepped minute never fires at.
1384
+ const pieces: string[] = [];
1385
+
1386
+ segments.forEach(function place(segment) {
1387
+ if (segment.kind === 'range') {
1388
+ pieces.push(timeRange(
1389
+ {hour: +segment.bounds[0], minute: 0},
1390
+ {hour: +segment.bounds[1], minute: 0}, opts));
1391
+ }
1392
+ else if (segment.kind === 'step') {
1393
+ segment.fires.forEach(function each(hour) {
1394
+ pieces.push(wholeHour(hour));
1395
+ });
1396
+ }
1397
+ else {
1398
+ pieces.push(wholeHour(+segment.value));
1399
+ }
1400
+ });
1401
+
1402
+ return joinList(pieces);
1403
+ }
1404
+
1405
+ // "à 9 h" / "à 1 h" / "à midi" for each fire hour.
1406
+ function atTimes(hours: number[], opts: Opts): string[] {
1407
+ return hours.map(function each(hour) {
1408
+ return atTime(timePhrase(hour, 0, null, opts));
1409
+ });
1410
+ }
1411
+
1412
+ // The hour times accompanying a lead clause: "à 9 h et 17 h", with long
1413
+ // expansions rendered segment by segment.
1414
+ function atHourTimes(
1415
+ schedule: Schedule,
1416
+ times: HourTimesPlan,
1417
+ opts: Opts
1418
+ ): string {
1419
+ if (times.kind === 'fires') {
1420
+ return groupClockTimesByArticle(atTimes(times.fires, opts));
1421
+ }
1422
+
1423
+ return hourSegmentTimes(schedule, 0, null, opts);
1424
+ }
1425
+
1426
+ // The active hours of a confined cadence: a few hours read as windows; many
1427
+ // read better as a compact list ("pendant les heures de 9 h, 11 h, 13 h, 15 h
1428
+ // et 17 h") than as a sprawl of windows.
1429
+ function hourSpanFromTimes(
1430
+ schedule: Schedule, times: HourTimesPlan, opts: Opts
1431
+ ): string {
1432
+ if (times.kind === 'fires' && times.fires.length > 3) {
1433
+ return 'pendant les heures ' + hourSpanList(times.fires, opts);
1434
+ }
1435
+
1436
+ return hourWindowsFromTimes(schedule, times, opts);
1437
+ }
1438
+
1439
+ // Each fire hour as its own one-hour window: "de 9 h à 9 h 59 et de 17 h à
1440
+ // 17 h 59". French prefers this to the English "during the 9 a.m. and 5 p.m.
1441
+ // hours" shape.
1442
+ function hourWindowsFromTimes(
1443
+ schedule: Schedule,
1444
+ times: HourTimesPlan,
1445
+ opts: Opts
1446
+ ): string {
1447
+ if (times.kind === 'fires') {
1448
+ return joinList(times.fires.map(function window(hour) {
1449
+ return hourAsWindow(hour, opts);
1450
+ }));
1451
+ }
1452
+
1453
+ return joinList(segmentsOf(schedule, 'hour').map(function window(segment) {
1454
+ if (segment.kind === 'range') {
1455
+ return timeRange({hour: +segment.bounds[0], minute: 0},
1456
+ {hour: +segment.bounds[1], minute: 59}, opts);
1457
+ }
1458
+
1459
+ if (segment.kind === 'step') {
1460
+ return joinList(segment.fires.map(function each(hour) {
1461
+ return hourAsWindow(hour, opts);
1462
+ }));
1463
+ }
1464
+
1465
+ return hourAsWindow(+segment.value, opts);
1466
+ }));
1467
+ }
1468
+
1469
+ // Clock times for the hour field rendered segment by segment, the minute (and
1470
+ // optional second) folded into each: "de 9 h 30 à 20 h 30 et aussi à 22 h 30"
1471
+ // when an isolated point-time follows a range.
1472
+ function hourSegmentTimes(
1473
+ schedule: Schedule,
1474
+ minute: number,
1475
+ second: number | null | undefined,
1476
+ opts: Opts
1477
+ ): string {
1478
+ // Track whether each piece came from a range (true) or a point (false).
1479
+ const pieces: string[] = [];
1480
+ const fromRange: boolean[] = [];
1481
+
1482
+ segmentsOf(schedule, 'hour').forEach(function clock(segment) {
1483
+ if (segment.kind === 'step') {
1484
+ segment.fires.forEach(function each(hour) {
1485
+ pieces.push(atTime(timePhrase(hour, minute, second, opts)));
1486
+ fromRange.push(false);
1487
+ });
1488
+ }
1489
+ else if (segment.kind === 'range') {
1490
+ pieces.push(timeRange(
1491
+ {hour: +segment.bounds[0], minute, second},
1492
+ {hour: +segment.bounds[1], minute, second}, opts));
1493
+ fromRange.push(true);
1494
+ }
1495
+ else {
1496
+ pieces.push(atTime(timePhrase(+segment.value, minute, second, opts)));
1497
+ fromRange.push(false);
1498
+ }
1499
+ });
1500
+
1501
+ // When the last piece is an isolated point-time that follows a range, join it
1502
+ // with "et aussi" so it is not read as the range extending.
1503
+ const lastIdx = pieces.length - 1;
1504
+ const hasRange = fromRange.some(function ranged(r) {
1505
+ return r;
1506
+ });
1507
+ const lastIsPoint = lastIdx >= 1 && !fromRange[lastIdx] &&
1508
+ fromRange[lastIdx - 1];
1509
+
1510
+ if (hasRange && lastIsPoint) {
1511
+ return joinList(pieces.slice(0, lastIdx)) + ' et aussi ' + pieces[lastIdx];
1512
+ }
1513
+
1514
+ return groupClockTimesByArticle(pieces);
1515
+ }
1516
+
1517
+ // --- Times. ---
1518
+
1519
+ // A time range, "de 9 h à 17 h 45", between two `{hour, minute, second}` ends.
1520
+ // A one-hour window (from and to share the hour, e.g. 0:00–0:59) renders both
1521
+ // endpoints numerically so a 0/12-hour window reads "de 0 h à 0 h 59", never
1522
+ // the bare-instant "de minuit à 0 h 59" (notes.md: minuit/midi are the exact
1523
+ // POINT only, never an endpoint of a 0:00–0:59 / 12:00–12:59 window).
1524
+ function timeRange(
1525
+ from: ClockEnd,
1526
+ to: ClockEnd,
1527
+ opts: Opts
1528
+ ): string {
1529
+ if (from.hour === to.hour) {
1530
+ return fromTime(clockNumeric(from.hour, from.minute, from.second, opts)) +
1531
+ ' ' + toTime(clockNumeric(to.hour, to.minute, to.second, opts));
1532
+ }
1533
+
1534
+ const fromPhrase = timePhrase(from.hour, from.minute, from.second, opts);
1535
+ const toPhrase = timePhrase(to.hour, to.minute, to.second, opts);
1536
+
1537
+ return fromTime(fromPhrase) + ' ' + toTime(toPhrase);
1538
+ }
1539
+
1540
+ // A one-hour window, "de 9 h à 9 h 59".
1541
+ function hourAsWindow(hour: number, opts: Opts): string {
1542
+ return timeRange({hour, minute: 0}, {hour, minute: 59}, opts);
1543
+ }
1544
+
1545
+ // "à 9 h 30" / "à 1 h" / "à minuit" / "à midi". fr clock times take no article,
1546
+ // so the preposition is a plain prefix.
1547
+ function atTime(phrase: string): string {
1548
+ return 'à ' + phrase;
1549
+ }
1550
+
1551
+ // "de 9 h 30" / "de minuit" / "de midi". A plain "de" prefix (no article on the
1552
+ // clock, so no contraction).
1553
+ function fromTime(phrase: string): string {
1554
+ return 'de ' + phrase;
1555
+ }
1556
+
1557
+ // "à 17 h 45" as the closing end of a range.
1558
+ function toTime(phrase: string): string {
1559
+ return atTime(phrase);
1560
+ }
1561
+
1562
+ // A clock time, no article: "9 h 30", "1 h", "minuit", "midi", or "9 h 30 s" /
1563
+ // "9 h 30 min 15 s" when a second folds in. Exact 0:00 / 12:00 (minute 0, no
1564
+ // second) read as the bare nouns minuit / midi; every other time is numeric.
1565
+ function timePhrase(
1566
+ hour: number,
1567
+ minute: number,
1568
+ second: number | null | undefined,
1569
+ opts: Opts
1570
+ ): string {
1571
+ const showSeconds = typeof second === 'number' && second > 0 ? second : 0;
1572
+
1573
+ if (!showSeconds && +minute === 0) {
1574
+ if (+hour === 0) {
1575
+ return 'minuit';
1576
+ }
1577
+
1578
+ if (+hour === 12) {
1579
+ return 'midi';
1580
+ }
1581
+ }
1582
+
1583
+ return clockNumeric(hour, minute, showSeconds, opts);
1584
+ }
1585
+
1586
+ // A bare hour with no minutes and the noon/midnight words: "9 h" / "minuit" /
1587
+ // "midi". Used by the duration frame and the whole-hour idiom, where the minute
1588
+ // is already accounted for and the clock minute would only mislead.
1589
+ function barePoint(hour: number, opts: Opts): string {
1590
+ if (+hour === 0) {
1591
+ return 'minuit';
1592
+ }
1593
+
1594
+ if (+hour === 12) {
1595
+ return 'midi';
1596
+ }
1597
+
1598
+ return clockNumeric(hour, 0, null, opts);
1599
+ }
1600
+
1601
+ // The numeric clock form, no article and no noon/midnight words: the spaced
1602
+ // "9 h 30" / "9 h" default (unpadded hour and minute, the typographic "h"
1603
+ // mark), the unspaced "9h30" casual register, or a custom separator ("9:30").
1604
+ // A non-zero second folds in as "9 h 30 min 15 s" with the zero-minute segment
1605
+ // suppressed ("9 h 30 s"); seconds are only ever fed in the default "h" mode.
1606
+ function clockNumeric(
1607
+ hour: number,
1608
+ minute: number,
1609
+ second: number | null | undefined,
1610
+ opts: Opts
1611
+ ): string {
1612
+ const showSeconds = typeof second === 'number' && second > 0 ? second : 0;
1613
+
1614
+ if (opts.style.sep !== 'h') {
1615
+ // A custom separator (e.g. the colon register): unpadded hour, padded
1616
+ // minute, so "17:30" reads as a conventional digital clock.
1617
+ const base = hour + opts.style.sep + pad(minute);
1618
+
1619
+ return showSeconds ? base + opts.style.sep + pad(showSeconds) : base;
1620
+ }
1621
+
1622
+ const space = opts.style.unspaced ? '' : ' ';
1623
+ const hourPart = hour + space + 'h';
1624
+
1625
+ if (showSeconds) {
1626
+ // The seconds clock keeps "min" only when the minute is non-zero, dropping
1627
+ // the "0 min" segment ("9 h 30 s", not "9 h 0 min 30 s").
1628
+ const minutePart = minute ? space + minute + space + 'min' : '';
1629
+
1630
+ return hourPart + minutePart + space + showSeconds + space + 's';
1631
+ }
1632
+
1633
+ return minute ? hourPart + space + minute : hourPart;
1634
+ }
1635
+
1636
+ // --- Day-level qualifiers. ---
1637
+
1638
+ // The qualifier that precedes clock times: "tous les jours ", "le lundi ",
1639
+ // "le 13 de chaque mois ", "du lundi au vendredi ".
1640
+ // Date-OR-weekday unions skip this entirely — the unified frame in `render`
1641
+ // handles the month lead and day-level suffix.
1642
+ function leadingQualifier(schedule: Schedule, opts: Opts): string {
1643
+ const pattern = schedule.pattern;
1644
+
1645
+ if (pattern.date !== '*' && pattern.weekday !== '*') {
1646
+ return '';
1647
+ }
1648
+
1649
+ if (pattern.date !== '*') {
1650
+ return datePhrase(schedule, opts) + ' ';
1651
+ }
1652
+
1653
+ if (pattern.weekday !== '*') {
1654
+ return weekdayQualifier(schedule) + monthScope(schedule) + ' ';
1655
+ }
1656
+
1657
+ if (pattern.month !== '*') {
1658
+ return 'tous les jours ' + monthPhrase(schedule, 'de ') + ' ';
1659
+ }
1660
+
1661
+ return 'tous les jours ';
1662
+ }
1663
+
1664
+ // The qualifier trailing a frequency: " le lundi", " en juin", " le 13 de
1665
+ // chaque mois". Empty when no day-level field is set.
1666
+ // Date-OR-weekday unions skip this entirely — the unified frame in `render`
1667
+ // handles the month lead and day-level suffix.
1668
+ function trailingQualifier(schedule: Schedule, opts: Opts): string {
1669
+ const pattern = schedule.pattern;
1670
+
1671
+ if (pattern.date !== '*' && pattern.weekday !== '*') {
1672
+ return '';
1673
+ }
1674
+
1675
+ if (pattern.date !== '*') {
1676
+ return ' ' + datePhrase(schedule, opts);
1677
+ }
1678
+
1679
+ if (pattern.weekday !== '*') {
1680
+ return ' ' + weekdayQualifier(schedule) + monthScope(schedule);
1681
+ }
1682
+
1683
+ if (pattern.month !== '*') {
1684
+ return ' ' + monthPhrase(schedule, 'en ');
1685
+ }
1686
+
1687
+ return '';
1688
+ }
1689
+
1690
+ // The date qualifier: "le 13 juin", "le 1er et le 15 de chaque mois", "du 1er
1691
+ // au 15 de chaque mois", or a Quartz phrase. A foldable single year joins the
1692
+ // date ("le 25 décembre 2030").
1693
+ function datePhrase(schedule: Schedule, opts: Opts): string {
1694
+ const pattern = schedule.pattern;
1695
+
1696
+ if (quartzDatePhrase(pattern.date) || isOpenStep(pattern.date)) {
1697
+ return dateClause(schedule, '', opts) + monthScope(schedule);
1698
+ }
1699
+
1700
+ return dateClause(schedule, dateMonthPart(schedule), opts);
1701
+ }
1702
+
1703
+ // The date words with a caller-chosen month part. Quartz phrases and open steps
1704
+ // are self-contained and ignore the month part.
1705
+ function dateClause(
1706
+ schedule: Schedule,
1707
+ monthPart: string,
1708
+ opts: Opts
1709
+ ): string {
1710
+ const pattern = schedule.pattern;
1711
+ const quartz = quartzDatePhrase(pattern.date);
1712
+
1713
+ if (quartz) {
1714
+ return quartz;
1715
+ }
1716
+
1717
+ if (isOpenStep(pattern.date)) {
1718
+ return stepDates(pattern.date, opts);
1719
+ }
1720
+
1721
+ const segments = segmentsOf(schedule, 'date');
1722
+
1723
+ if (segments.length === 1 && segments[0].kind === 'range') {
1724
+ return 'du ' + dayOrdinal(segments[0].bounds[0]) + ' au ' +
1725
+ segments[0].bounds[1] + monthPart + foldedYear(schedule);
1726
+ }
1727
+
1728
+ if (segments.length === 1 && segments[0].kind === 'single') {
1729
+ return 'le ' + dayOrdinal(segments[0].value) + monthPart +
1730
+ foldedYear(schedule);
1731
+ }
1732
+
1733
+ return joinList(dateWords(segments)) + monthPart + foldedYear(schedule);
1734
+ }
1735
+
1736
+ // Whether the month field contains a range segment.
1737
+ function monthRanged(schedule: Schedule): boolean {
1738
+ return schedule.pattern.month !== '*' &&
1739
+ segmentsOf(schedule, 'month').some(function range(segment) {
1740
+ return segment.kind === 'range';
1741
+ });
1742
+ }
1743
+
1744
+ // The month attached to a calendar date. Single months and flat name lists
1745
+ // fold in ("le 1er juin et décembre"), but a range cannot — "le 1er juin à
1746
+ // septembre" parses as "(le 1er juin) à septembre" — so it scopes the date
1747
+ // instead ("le 1er de chaque mois, de juin à septembre").
1748
+ function dateMonthPart(schedule: Schedule): string {
1749
+ if (schedule.pattern.month === '*') {
1750
+ return ' de chaque mois';
1751
+ }
1752
+
1753
+ if (monthRanged(schedule)) {
1754
+ return ' de chaque mois, ' + monthPhrase(schedule, 'de ');
1755
+ }
1756
+
1757
+ // A calendar date carries its month bare in French — "le 1er janvier", "le
1758
+ // 25 décembre" — with no "de" (unlike the es donor's "el 1 de junio"). A
1759
+ // month list under a date stays bare too ("le 1er janvier, avril, juillet").
1760
+ return ' ' + monthPhrase(schedule, '');
1761
+ }
1762
+
1763
+ // " 2030" when a single year can fold into a calendar date ("le 25 décembre
1764
+ // 2030"): French dates carry the year bare, no preposition.
1765
+ function foldedYear(schedule: Schedule): string {
1766
+ const yearField = schedule.pattern.year;
1767
+
1768
+ if (yearField === '*' || yearField.indexOf('/') !== -1 ||
1769
+ yearField.indexOf('-') !== -1 || yearField.indexOf(',') !== -1) {
1770
+ return '';
1771
+ }
1772
+
1773
+ return ' ' + yearField;
1774
+ }
1775
+
1776
+ // The Quartz date phrases.
1777
+ function quartzDatePhrase(dateField: string): string | undefined {
1778
+ if (dateField === 'L') {
1779
+ return 'le dernier jour du mois';
1780
+ }
1781
+
1782
+ if (dateField === 'LW' || dateField === 'WL') {
1783
+ return 'le dernier jour ouvrable du mois';
1784
+ }
1785
+
1786
+ const offset = (/^L-(\d{1,2})$/).exec(dateField);
1787
+
1788
+ if (offset) {
1789
+ return +offset[1] === 1 ?
1790
+ 'un jour avant le dernier jour du mois' :
1791
+ offset[1] + ' jours avant le dernier jour du mois';
1792
+ }
1793
+
1794
+ const nearest = (/^(\d{1,2})W$|^W(\d{1,2})$/).exec(dateField);
1795
+
1796
+ if (nearest) {
1797
+ return 'le jour ouvrable le plus proche du ' +
1798
+ (nearest[1] || nearest[2]);
1799
+ }
1800
+ }
1801
+
1802
+ // The Quartz weekday phrases: "le dernier vendredi du mois", "le deuxième lundi
1803
+ // du mois". The nth-weekday ordinal agrees with the (masculine) weekday.
1804
+ function quartzWeekdayPhrase(weekdayField: string): string | undefined {
1805
+ const parts = weekdayField.split('#');
1806
+
1807
+ if (parts.length === 2) {
1808
+ return 'le ' + nthWeekdayMasculine[+parts[1]] + ' ' +
1809
+ weekdayName(parts[0]) + ' du mois';
1810
+ }
1811
+
1812
+ if ((/L$/).test(weekdayField)) {
1813
+ return 'le dernier ' + weekdayName(weekdayField.slice(0, -1)) +
1814
+ ' du mois';
1815
+ }
1816
+ }
1817
+
1818
+ // The weekday qualifier: "le lundi", "du lundi au vendredi", "le lundi, le
1819
+ // mercredi et le vendredi". The singular definite article ("le lundi") already
1820
+ // conveys "every Monday" in French, so no "tous les" prefix and no plural.
1821
+ function weekdayQualifier(schedule: Schedule): string {
1822
+ const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
1823
+
1824
+ if (quartz) {
1825
+ return quartz;
1826
+ }
1827
+
1828
+ // Weekday lists display Monday-first (Sunday last); a lone range keeps its
1829
+ // form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
1830
+ const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
1831
+ const allSingles = segments.every(function single(segment) {
1832
+ return segment.kind === 'single';
1833
+ });
1834
+
1835
+ if (allSingles) {
1836
+ return recurringWeekdayList(segments as SingleNameSegment[]);
1837
+ }
1838
+
1839
+ // A single plain range stands alone: "du lundi au vendredi". Reaching here
1840
+ // means not all-singles with a single segment, i.e. a lone range.
1841
+ if (segments.length === 1) {
1842
+ return weekdayRange(segments[0] as RangeNameSegment);
1843
+ }
1844
+
1845
+ // Mixed lists: each piece carries its own form.
1846
+ return mixedWeekdayList(segments);
1847
+ }
1848
+
1849
+ // A list of single weekdays as the recurrence: each repeats the singular
1850
+ // definite article ("le lundi, le mercredi et le vendredi"), the
1851
+ // singular-definite habitual form (notes.md: not the es plural "les lundis").
1852
+ function recurringWeekdayList(segments: SingleNameSegment[]): string {
1853
+ return joinList(segments.map(function name(segment) {
1854
+ return 'le ' + weekdayName(segment.value);
1855
+ }));
1856
+ }
1857
+
1858
+ // A mixed weekday list (ranges + singles), each piece carrying its own form:
1859
+ // ranges read "du X au Y", singles read the recurrence "le X". Used in the
1860
+ // standalone qualifier and the OR-union dow arm.
1861
+ function mixedWeekdayList(segments: NameSegment[]): string {
1862
+ return joinList(segments.map(function name(segment) {
1863
+ return segment.kind === 'range' ?
1864
+ weekdayRange(segment) :
1865
+ 'le ' + weekdayName(segment.value);
1866
+ }));
1867
+ }
1868
+
1869
+ // "du lundi au vendredi": the idiomatic fr weekday range (de+le → du, à+le →
1870
+ // au).
1871
+ function weekdayRange(segment: RangeNameSegment): string {
1872
+ return 'du ' + weekdayName(segment.bounds[0]) + ' au ' +
1873
+ weekdayName(segment.bounds[1]);
1874
+ }
1875
+
1876
+ // Expand step segments into their fires as singles: a raw step token or a
1877
+ // nested sub-list garbles a name list, while the flat fires read naturally
1878
+ // ("le mardi, le jeudi, le samedi et le dimanche").
1879
+ function flattenSteps(segments: Segment[]): NameSegment[] {
1880
+ return segments.flatMap(function flat(segment): NameSegment[] {
1881
+ return segment.kind === 'step' ?
1882
+ segment.fires.map(function single(value): NameSegment {
1883
+ return {kind: 'single', value};
1884
+ }) :
1885
+ [segment];
1886
+ });
1887
+ }
1888
+
1889
+ // The month qualifier with its preposition. Plain name lists distribute the
1890
+ // caller's preposition ("de juin et décembre", "en janvier et juillet"); step
1891
+ // segments flatten into their fires. A range always reads "de X à Y" as one
1892
+ // unit, so in mixed lists every piece repeats its preposition ("en janvier et
1893
+ // de mars à juin") — a bare "janvier et mars à juin" parses as "(janvier et
1894
+ // mars) à juin".
1895
+ function monthPhrase(schedule: Schedule, lead: string): string {
1896
+ const segments = flattenSteps(segmentsOf(schedule, 'month'));
1897
+ const ranged = segments.some(function range(segment) {
1898
+ return segment.kind === 'range';
1899
+ });
1900
+
1901
+ if (!ranged) {
1902
+ // No ranges remain, so every segment is a single with a `value`.
1903
+ return lead + joinList(segments.map(function name(segment) {
1904
+ return monthName((segment as SingleNameSegment).value);
1905
+ }));
1906
+ }
1907
+
1908
+ return joinList(segments.map(function name(segment) {
1909
+ if (segment.kind === 'range') {
1910
+ return 'de ' + monthName(segment.bounds[0]) + ' à ' +
1911
+ monthName(segment.bounds[1]);
1912
+ }
1913
+
1914
+ return lead + monthName(segment.value);
1915
+ }));
1916
+ }
1917
+
1918
+ // A trailing " de <month>" scope on weekday qualifiers ("le lundi de juin"). A
1919
+ // ranged scope sets off with a comma ("le dernier jour du mois, de juin à
1920
+ // septembre") — gluing "de juin" after "du mois" garden-paths.
1921
+ function monthScope(schedule: Schedule): string {
1922
+ if (schedule.pattern.month === '*') {
1923
+ return '';
1924
+ }
1925
+
1926
+ return (monthRanged(schedule) ? ', ' : ' ') + monthPhrase(schedule, 'de ');
1927
+ }
1928
+
1929
+ // The parity predicate for a `*/2`-style day-of-month step, used only inside
1930
+ // the OR union frame (see domArm). `*/2` and `1/2` fire on the odd days
1931
+ // (1, 3, …, 31); `2/2` fires on the even days. Any other open step has no
1932
+ // parity reading, so the caller falls back to stepDates.
1933
+ function parityDayPredicate(dateField: string): string | undefined {
1934
+ if (!isOpenStep(dateField)) {
1935
+ return;
1936
+ }
1937
+
1938
+ const [start, step] = dateField.split('/');
1939
+
1940
+ if (+step !== 2) {
1941
+ return;
1942
+ }
1943
+
1944
+ if (start === '*' || start === '1') {
1945
+ return 'un jour impair du mois';
1946
+ }
1947
+
1948
+ if (start === '2') {
1949
+ return 'un jour pair du mois';
1950
+ }
1951
+ }
1952
+
1953
+ // Open day-of-month steps: "tous les 2 jours du mois (à partir du 5)".
1954
+ function stepDates(dateField: string, opts: Opts): string {
1955
+ const parts = dateField.split('/');
1956
+ let phrase = 'tous les ' + numero(+parts[1], opts) + ' jours du mois';
1957
+
1958
+ if (parts[0] !== '*' && parts[0] !== '1') {
1959
+ phrase += ' à partir du ' + parts[0];
1960
+ }
1961
+
1962
+ return phrase;
1963
+ }
1964
+
1965
+ // --- Years. ---
1966
+
1967
+ // Append the year when it has not folded into a calendar date: "en 2030", "en
1968
+ // 2030 et 2031", "tous les deux ans à partir de 2030".
1969
+ function applyYear(
1970
+ description: string,
1971
+ schedule: Schedule,
1972
+ opts: Opts
1973
+ ): string {
1974
+ const yearField = schedule.pattern.year;
1975
+
1976
+ if (yearField === '*') {
1977
+ return description;
1978
+ }
1979
+
1980
+ if (yearField.indexOf('/') !== -1) {
1981
+ return description + ' ' + stepYears(yearField, opts);
1982
+ }
1983
+
1984
+ // A foldable single year already joined its date in datePhrase.
1985
+ if (foldedYear(schedule) && schedule.pattern.date !== '*') {
1986
+ return description;
1987
+ }
1988
+
1989
+ if (yearField.indexOf(',') !== -1) {
1990
+ return description + ' en ' + joinList(yearField.split(','));
1991
+ }
1992
+
1993
+ return description + ' en ' + yearField;
1994
+ }
1995
+
1996
+ // "tous les deux ans (à partir de 2030)" / "chaque année".
1997
+ function stepYears(yearField: string, opts: Opts): string {
1998
+ const parts = yearField.split('/');
1999
+ const interval = +parts[1];
2000
+
2001
+ if (interval <= 1) {
2002
+ return 'chaque année';
2003
+ }
2004
+
2005
+ let phrase = 'tous les ' + numero(interval, opts) + ' ans';
2006
+
2007
+ if (parts[0] !== '*' && parts[0] !== '0') {
2008
+ phrase += ' à partir de ' + parts[0];
2009
+ }
2010
+
2011
+ return phrase;
2012
+ }
2013
+
2014
+ // --- Words. ---
2015
+
2016
+ // Render classified segments as words: ranges as "5 à 10" pairs, steps as their
2017
+ // enumerated fires.
2018
+ function segmentWords(segments: Segment[]): string[] {
2019
+ return segments.flatMap(function word(segment) {
2020
+ if (segment.kind === 'range') {
2021
+ return [segment.bounds[0] + ' à ' + segment.bounds[1]];
2022
+ }
2023
+
2024
+ if (segment.kind === 'step') {
2025
+ return wordList(segment.fires);
2026
+ }
2027
+
2028
+ return [segment.value];
2029
+ });
2030
+ }
2031
+
2032
+ // Render date segments as "le N" words, with the 1st of the month as the
2033
+ // ordinal "le 1er" and every other day cardinal ("le 2"). Ranges carry the
2034
+ // ordinal on the first term and cardinal on the rest.
2035
+ function dateWords(segments: Segment[]): string[] {
2036
+ return segments.flatMap(function word(segment) {
2037
+ if (segment.kind === 'range') {
2038
+ return ['du ' + dayOrdinal(segment.bounds[0]) + ' au ' +
2039
+ segment.bounds[1]];
2040
+ }
2041
+
2042
+ if (segment.kind === 'step') {
2043
+ return segment.fires.map(function fire(value) {
2044
+ return 'le ' + dayOrdinal(value);
2045
+ });
2046
+ }
2047
+
2048
+ return ['le ' + dayOrdinal(segment.value)];
2049
+ });
2050
+ }
2051
+
2052
+ // The day-of-month value as words: the 1st is the ordinal "1er" (a deep fr-FR
2053
+ // norm — calendars, official texts, speech); every other day stays cardinal.
2054
+ function dayOrdinal(value: NameToken): string {
2055
+ return +value === 1 ? '1er' : '' + value;
2056
+ }
2057
+
2058
+ // Numeric fire values as digits.
2059
+ function wordList(fires: number[]): string[] {
2060
+ return fires.map(function digit(value) {
2061
+ return '' + value;
2062
+ });
2063
+ }
2064
+
2065
+ // Join a list with commas and a terminal "et". French never takes a comma
2066
+ // before "et" in a simple series.
2067
+ function joinList(items: string[]): string {
2068
+ if (items.length <= 1) {
2069
+ return items.join('');
2070
+ }
2071
+
2072
+ if (items.length === 2) {
2073
+ return items[0] + ' et ' + items[1];
2074
+ }
2075
+
2076
+ return items.slice(0, -1).join(', ') + ' et ' + items[items.length - 1];
2077
+ }
2078
+
2079
+ // Spell the integers zero through ten ("toutes les cinq minutes"); digits
2080
+ // otherwise, and always with `short`. French cardinals 0-10 are invariant, so
2081
+ // no gendered form is needed (unlike pt's "duas").
2082
+ function numero(n: number, opts: Opts): string | number {
2083
+ return numeral(n, numeros, opts);
2084
+ }
2085
+
2086
+ // A weekday name from a canonical number, or from a Quartz stem (`5L`,
2087
+ // `MON#2`), which the core does not number-canonicalize: resolve any name via
2088
+ // the core's index and fold the Sunday alias 7 to 0.
2089
+ function weekdayName(token: NameToken): string {
2090
+ const number = toFieldNumber('' + token, weekdayNumbers);
2091
+
2092
+ return weekdayNames[number === 7 ? 0 : number];
2093
+ }
2094
+
2095
+ // A month name from a canonical month number. The name array has a leading null
2096
+ // hole for the 1-based index.
2097
+ function monthName(token: NameToken): string {
2098
+ return monthNames[+token] as string;
2099
+ }
2100
+
2101
+
2102
+ // The French language module: the Schedule renderer plus the language-owned
2103
+ // strings and option normalization.
2104
+ const fr: Language<FrenchStyle> = {
2105
+ describe,
2106
+ fallback: 'un motif cron non reconnu',
2107
+ options: normalizeOptions,
2108
+ reboot: 'au démarrage du système',
2109
+ // A description ending in a period already carries it, so closing the
2110
+ // sentence must not double it.
2111
+ sentence: (description) =>
2112
+ 'S\'exécute ' + description + (description.endsWith('.') ? '' : '.')
2113
+ };
2114
+
2115
+ export default fr;