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.
- package/CHANGELOG.md +141 -0
- package/README.md +41 -8
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +13 -6
- package/dist/cronli5.js +13 -6
- package/dist/lang/de.cjs +13 -6
- package/dist/lang/de.js +13 -6
- package/dist/lang/en.cjs +13 -6
- package/dist/lang/en.js +13 -6
- package/dist/lang/es.cjs +13 -6
- package/dist/lang/es.js +13 -6
- package/dist/lang/fi.cjs +13 -6
- package/dist/lang/fi.js +13 -6
- package/dist/lang/fr.cjs +1210 -0
- package/dist/lang/fr.js +1186 -0
- package/dist/lang/pt.cjs +1591 -0
- package/dist/lang/pt.js +1567 -0
- package/dist/lang/zh.cjs +73 -17
- package/dist/lang/zh.js +73 -17
- package/package.json +18 -7
- package/src/core/cadence.ts +25 -12
- package/src/lang/de/index.ts +2 -2
- package/src/lang/en/index.ts +2 -2
- package/src/lang/es/index.ts +2 -2
- package/src/lang/fi/index.ts +2 -2
- package/src/lang/fr/dialects.ts +49 -0
- package/src/lang/fr/index.ts +2115 -0
- package/src/lang/fr/notes.md +280 -0
- package/src/lang/fr/status.json +8 -0
- package/src/lang/pt/dialects.ts +56 -0
- package/src/lang/pt/index.ts +2803 -0
- package/src/lang/pt/notes.md +199 -0
- package/src/lang/pt/status.json +8 -0
- package/src/lang/zh/index.ts +106 -17
- package/src/lang/zh/notes.md +16 -4
- package/src/lang/zh/status.json +10 -1
- package/types/core/cadence.d.ts +1 -0
- package/types/lang/fr/dialects.d.ts +11 -0
- package/types/lang/fr/index.d.ts +4 -0
- package/types/lang/pt/dialects.d.ts +13 -0
- package/types/lang/pt/index.d.ts +4 -0
|
@@ -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;
|