cronli5 0.3.4 → 0.8.0
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 +120 -0
- package/README.md +63 -13
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +72 -9
- package/dist/cronli5.js +72 -9
- package/dist/lang/de.cjs +14 -6
- package/dist/lang/de.js +14 -6
- package/dist/lang/en.cjs +14 -6
- package/dist/lang/en.js +14 -6
- package/dist/lang/es.cjs +14 -6
- package/dist/lang/es.js +14 -6
- package/dist/lang/fi.cjs +14 -6
- package/dist/lang/fi.js +14 -6
- package/dist/lang/fr.cjs +1211 -0
- package/dist/lang/fr.js +1187 -0
- package/dist/lang/pt.cjs +1592 -0
- package/dist/lang/pt.js +1568 -0
- package/dist/lang/zh.cjs +58 -9
- package/dist/lang/zh.js +58 -9
- package/package.json +13 -2
- package/src/core/cadence.ts +25 -12
- package/src/core/index.ts +7 -3
- package/src/core/quartz.ts +97 -0
- package/src/core/schedule.ts +1 -0
- package/src/core/specs.ts +2 -2
- package/src/cronli5.ts +20 -3
- package/src/lang/de/index.ts +3 -2
- package/src/lang/en/index.ts +3 -2
- package/src/lang/es/index.ts +3 -2
- package/src/lang/fi/index.ts +3 -2
- package/src/lang/fr/dialects.ts +49 -0
- package/src/lang/fr/index.ts +2116 -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 +2804 -0
- package/src/lang/pt/notes.md +199 -0
- package/src/lang/pt/status.json +8 -0
- package/src/lang/zh/index.ts +61 -5
- package/src/lang/zh/notes.md +16 -4
- package/src/lang/zh/status.json +10 -1
- package/src/types.ts +44 -0
- package/types/core/cadence.d.ts +1 -0
- package/types/core/quartz.d.ts +4 -0
- package/types/core/schedule.d.ts +1 -0
- package/types/cronli5.d.ts +4 -4
- 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
- package/types/types.d.ts +39 -0
|
@@ -0,0 +1,2804 @@
|
|
|
1
|
+
// The Portuguese language module: renders an analyzed cron pattern (the
|
|
2
|
+
// Schedule produced by core `analyze`) as natural Brazilian Portuguese.
|
|
3
|
+
// Anchored to the pt-BR norm (VOLP / Academia Brasileira de Letras, plus
|
|
4
|
+
// cronstrue `pt_BR`); see notes.md for the decisions and trade-offs.
|
|
5
|
+
//
|
|
6
|
+
// pt is sibling-derived from es (docs/i18n-design.md §7, the language
|
|
7
|
+
// pipeline): it ports the Spanish module's STRUCTURE — the plan override, the
|
|
8
|
+
// OR-union frame, the parity predicates, the re-strategies, the dialect
|
|
9
|
+
// mechanism — and translates the lexicon, then diverges where Portuguese
|
|
10
|
+
// grammar genuinely differs: preposition+article contraction (do/da/no/na/à),
|
|
11
|
+
// gender agreement (feminine weekdays, gendered ordinals and determiners), the
|
|
12
|
+
// "toda X" single-weekday recurrence, "na 2ª segunda-feira", no comma before
|
|
13
|
+
// "e", and the ordinal "dia 1º". A language never imports another (this is a
|
|
14
|
+
// copy-and-translate of es, not an import); the only shared dependency is core.
|
|
15
|
+
|
|
16
|
+
import {clockDigits, numeral} from '../../core/format.js';
|
|
17
|
+
import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
|
|
18
|
+
import {isOpenStep} from '../../core/shapes.js';
|
|
19
|
+
import {
|
|
20
|
+
arithmeticStep, hourListStride, offsetCleanStride,
|
|
21
|
+
renderStride as chooseStride, segmentsOf, singleValues, stepSegment
|
|
22
|
+
} from '../../core/cadence.js';
|
|
23
|
+
import {orderWeekdaysForDisplay} from '../../core/weekday.js';
|
|
24
|
+
import {toFieldNumber} from '../../core/util.js';
|
|
25
|
+
import type {Cronli5Options} from '../../types.js';
|
|
26
|
+
import type {
|
|
27
|
+
HourTimesPlan, Schedule, Language, NormalizedOptions, PlanNode,
|
|
28
|
+
Segment
|
|
29
|
+
} from '../../core/schedule.js';
|
|
30
|
+
import {resolveDialect, type PortugueseStyle} from './dialects.js';
|
|
31
|
+
|
|
32
|
+
// Normalized options carrying Portuguese's own style shape.
|
|
33
|
+
type Opts = NormalizedOptions<PortugueseStyle>;
|
|
34
|
+
|
|
35
|
+
// The erased renderer signature the dispatch table maps to.
|
|
36
|
+
type Renderer = (schedule: Schedule, plan: PlanNode, opts: Opts) => string;
|
|
37
|
+
|
|
38
|
+
// A `step` segment, narrowed from the discriminated `Segment` union.
|
|
39
|
+
type StepSegment = Extract<Segment, {kind: 'step'}>;
|
|
40
|
+
|
|
41
|
+
// A step cadence to phrase: the `interval` repeats over a `cycle`-long field
|
|
42
|
+
// (60 for minute/second), running from `start` to `last`. `unit` is the
|
|
43
|
+
// singular noun and `anchor` the larger unit the values count against. When
|
|
44
|
+
// `anchor` is empty the caller supplies its own trailing scope, so the cadence
|
|
45
|
+
// drops the "de cada <anchor>" tail.
|
|
46
|
+
interface Stride {
|
|
47
|
+
interval: number;
|
|
48
|
+
start: number;
|
|
49
|
+
last: number;
|
|
50
|
+
cycle: number;
|
|
51
|
+
unit: string;
|
|
52
|
+
anchor: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One end of a clock-time range. The second is optional and may be absent
|
|
56
|
+
// (top-of-hour windows) or a folded clock second.
|
|
57
|
+
type ClockEnd = {hour: number; minute: number; second?: number | null};
|
|
58
|
+
|
|
59
|
+
// A name token: a cron name or numeric string from a segment, or a numeric
|
|
60
|
+
// fire that `flattenSteps` expands a step into.
|
|
61
|
+
type NameToken = string | number;
|
|
62
|
+
|
|
63
|
+
// A flattened name segment. `flattenSteps` turns step segments into single
|
|
64
|
+
// segments whose `value` is a numeric fire, so a single's value here may be
|
|
65
|
+
// a number as well as a `Segment`'s string token; ranges keep their bounds.
|
|
66
|
+
type NameSegment =
|
|
67
|
+
| {kind: 'single'; value: NameToken}
|
|
68
|
+
| {kind: 'range'; bounds: [string, string]};
|
|
69
|
+
|
|
70
|
+
// The range and single arms of a flattened name segment.
|
|
71
|
+
type RangeNameSegment = Extract<NameSegment, {kind: 'range'}>;
|
|
72
|
+
type SingleNameSegment = Extract<NameSegment, {kind: 'single'}>;
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
// Portuguese number names for the integers zero through ten.
|
|
76
|
+
const numeros = [
|
|
77
|
+
'zero',
|
|
78
|
+
'um',
|
|
79
|
+
'dois',
|
|
80
|
+
'três',
|
|
81
|
+
'quatro',
|
|
82
|
+
'cinco',
|
|
83
|
+
'seis',
|
|
84
|
+
'sete',
|
|
85
|
+
'oito',
|
|
86
|
+
'nove',
|
|
87
|
+
'dez'
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// Portuguese month names (lowercase, per VOLP).
|
|
91
|
+
const monthNames = [
|
|
92
|
+
null,
|
|
93
|
+
'janeiro',
|
|
94
|
+
'fevereiro',
|
|
95
|
+
'março',
|
|
96
|
+
'abril',
|
|
97
|
+
'maio',
|
|
98
|
+
'junho',
|
|
99
|
+
'julho',
|
|
100
|
+
'agosto',
|
|
101
|
+
'setembro',
|
|
102
|
+
'outubro',
|
|
103
|
+
'novembro',
|
|
104
|
+
'dezembro'
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// Portuguese weekday names (lowercase, per VOLP). Weekdays Mon-Fri carry the
|
|
108
|
+
// "-feira" element; sábado and domingo do not. The bare stem ("segunda") is
|
|
109
|
+
// kept separate so lists can suffix "-feira" on the last -feira day only and
|
|
110
|
+
// ranges on the last term only (notes.md §"Weekday recurrence").
|
|
111
|
+
const weekdayNames = [
|
|
112
|
+
'domingo',
|
|
113
|
+
'segunda-feira',
|
|
114
|
+
'terça-feira',
|
|
115
|
+
'quarta-feira',
|
|
116
|
+
'quinta-feira',
|
|
117
|
+
'sexta-feira',
|
|
118
|
+
'sábado'
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
// The bare weekday stems (no "-feira"), for list/range suffix-ellipsis.
|
|
122
|
+
const weekdayStems = [
|
|
123
|
+
'domingo',
|
|
124
|
+
'segunda',
|
|
125
|
+
'terça',
|
|
126
|
+
'quarta',
|
|
127
|
+
'quinta',
|
|
128
|
+
'sexta',
|
|
129
|
+
'sábado'
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// Whether a weekday (by canonical number) is feminine. The -feira days are
|
|
133
|
+
// feminine ("a segunda-feira" → "às segundas-feiras"); sábado and domingo are
|
|
134
|
+
// masculine ("o domingo" → "aos domingos"). Drives ordinal and article gender.
|
|
135
|
+
function weekdayFeminine(number: number): boolean {
|
|
136
|
+
return number !== 0 && number !== 6;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Gendered ordinals for Quartz `#` weekday occurrences (1-5). The renderer
|
|
140
|
+
// selects by the weekday's gender ("a primeira segunda-feira", "o último
|
|
141
|
+
// domingo"); es used invariant primer/último, pt must agree.
|
|
142
|
+
const nthWeekdayMasculine =
|
|
143
|
+
[null, 'primeiro', 'segundo', 'terceiro', 'quarto', 'quinto'];
|
|
144
|
+
const nthWeekdayFeminine =
|
|
145
|
+
[null, 'primeira', 'segunda', 'terceira', 'quarta', 'quinta'];
|
|
146
|
+
|
|
147
|
+
// --- Contractions (the principal es->pt divergence). ---
|
|
148
|
+
//
|
|
149
|
+
// Portuguese fuses a preposition with the following article wherever es emitted
|
|
150
|
+
// a bare preposition + article. These helpers form the contraction from a
|
|
151
|
+
// phrase that begins with a bare article ("a 01:00", "as 09:00", "o dia",
|
|
152
|
+
// "as segundas-feiras"): de+a/o/as/os -> da/do/das/dos; em+... -> na/no/nas;
|
|
153
|
+
// a+a/as -> à/às, a+o/os -> ao/aos. It is gender/number-driven formation, not a
|
|
154
|
+
// lexical substitution. Clock and weekday phrases carry their bare article so
|
|
155
|
+
// these fuse uniformly.
|
|
156
|
+
|
|
157
|
+
// The genitive noon/midnight words carry an implicit gendered article: meio-dia
|
|
158
|
+
// is masculine (o → ao/do), meia-noite feminine (a → à/da). These are words,
|
|
159
|
+
// not bare-article phrases, so the contraction helpers special-case them.
|
|
160
|
+
function noonMidnightArticle(phrase: string): 'o' | 'a' | null {
|
|
161
|
+
if (phrase === 'meio-dia') {
|
|
162
|
+
return 'o';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (phrase === 'meia-noite') {
|
|
166
|
+
return 'a';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The contraction of `a` + a bare-article phrase: a+a=à, a+as=às, a+o=ao,
|
|
173
|
+
// a+os=aos. Noon/midnight word forms carry an implicit gendered article
|
|
174
|
+
// (ao meio-dia, à meia-noite); any other word form falls through with "a".
|
|
175
|
+
function withA(phrase: string): string {
|
|
176
|
+
const word = noonMidnightArticle(phrase);
|
|
177
|
+
|
|
178
|
+
if (word) {
|
|
179
|
+
return (word === 'o' ? 'ao ' : 'à ') + phrase;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (phrase.startsWith('as ')) {
|
|
183
|
+
return 'às ' + phrase.slice(3);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (phrase.startsWith('a ')) {
|
|
187
|
+
return 'à ' + phrase.slice(2);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (phrase.startsWith('os ')) {
|
|
191
|
+
return 'aos ' + phrase.slice(3);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (phrase.startsWith('o ')) {
|
|
195
|
+
return 'ao ' + phrase.slice(2);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return 'a ' + phrase;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The contraction of `de` + a bare-article phrase: de+a=da, de+as=das,
|
|
202
|
+
// de+o=do, de+os=dos. Noon/midnight take their gendered article (do meio-dia,
|
|
203
|
+
// da meia-noite).
|
|
204
|
+
function withDe(phrase: string): string {
|
|
205
|
+
const word = noonMidnightArticle(phrase);
|
|
206
|
+
|
|
207
|
+
if (word) {
|
|
208
|
+
return (word === 'o' ? 'do ' : 'da ') + phrase;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (phrase.startsWith('as ')) {
|
|
212
|
+
return 'das ' + phrase.slice(3);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (phrase.startsWith('a ')) {
|
|
216
|
+
return 'da ' + phrase.slice(2);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (phrase.startsWith('os ')) {
|
|
220
|
+
return 'dos ' + phrase.slice(3);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (phrase.startsWith('o ')) {
|
|
224
|
+
return 'do ' + phrase.slice(2);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return 'de ' + phrase;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Whether a phrase begins with a bare article the contraction helpers fuse
|
|
231
|
+
// (or a noon/midnight word, which carries an implicit one). A phrase that does
|
|
232
|
+
// not — the Quartz "5 dias antes do último dia do mês" offset form — takes no
|
|
233
|
+
// preposition in the leading/arm position, so the caller leaves it bare.
|
|
234
|
+
function hasLeadingArticle(phrase: string): boolean {
|
|
235
|
+
return noonMidnightArticle(phrase) !== null ||
|
|
236
|
+
phrase.startsWith('a ') || phrase.startsWith('as ') ||
|
|
237
|
+
phrase.startsWith('o ') || phrase.startsWith('os ');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The contraction of `em` + a bare-article phrase: em+a=na, em+as=nas,
|
|
241
|
+
// em+o=no, em+os=nos.
|
|
242
|
+
function withEm(phrase: string): string {
|
|
243
|
+
if (phrase.startsWith('as ')) {
|
|
244
|
+
return 'nas ' + phrase.slice(3);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (phrase.startsWith('a ')) {
|
|
248
|
+
return 'na ' + phrase.slice(2);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (phrase.startsWith('os ')) {
|
|
252
|
+
return 'nos ' + phrase.slice(3);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (phrase.startsWith('o ')) {
|
|
256
|
+
return 'no ' + phrase.slice(2);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return 'em ' + phrase;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Normalize raw user options.
|
|
263
|
+
function normalizeOptions(options?: Cronli5Options): Opts {
|
|
264
|
+
options = options || {};
|
|
265
|
+
const style = resolveDialect(options.dialect);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
// The clock default comes from the dialect (24-hour for pt-BR); an explicit
|
|
269
|
+
// `{ampm}` option overrides it.
|
|
270
|
+
ampm: typeof options.ampm === 'boolean' ? options.ampm : style.ampm,
|
|
271
|
+
lenient: !!options.lenient,
|
|
272
|
+
quartz: !!options.quartz,
|
|
273
|
+
seconds: !!options.seconds,
|
|
274
|
+
short: !!options.short,
|
|
275
|
+
style,
|
|
276
|
+
years: !!options.years
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Render an analyzed cron pattern (the Schedule) as Portuguese.
|
|
281
|
+
function describe(schedule: Schedule, opts: Opts): string {
|
|
282
|
+
return applyYear(render(schedule, schedule.plan, opts), schedule, opts);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Render one plan node. `composeSeconds` recurses with its `rest` plan.
|
|
286
|
+
// When BOTH date and weekday are restricted (a date-OR-weekday union), the
|
|
287
|
+
// result is wrapped in the unified `[month] [time], seja <DOM> ou <DOW>`
|
|
288
|
+
// frame regardless of arm shapes or month type.
|
|
289
|
+
function render(schedule: Schedule, plan: PlanNode, opts: Opts): string {
|
|
290
|
+
// Each renderer narrows `plan` to its own `kind`; the dispatch table is
|
|
291
|
+
// keyed by that discriminant, so the union-to-specific match is sound but
|
|
292
|
+
// not expressible without a cast.
|
|
293
|
+
const phrase = (renderers[plan.kind] as Renderer)(schedule, plan, opts);
|
|
294
|
+
|
|
295
|
+
if (!isDateWeekdayUnion(schedule)) {
|
|
296
|
+
return phrase;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The time/frequency phrase arrives from the renderer with no day qualifier
|
|
300
|
+
// (leadingQualifier and trailingQualifier both return '' for union patterns).
|
|
301
|
+
// Front the shared month (possibly with a trailing comma for enumerations),
|
|
302
|
+
// then append the union correlative last.
|
|
303
|
+
const lead = unionMonthLeadFull(schedule);
|
|
304
|
+
|
|
305
|
+
return (lead ? lead + ' ' : '') + phrase + unionSejaSuffix(schedule, opts);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// --- Seconds renderers. ---
|
|
309
|
+
|
|
310
|
+
function renderEverySecond(
|
|
311
|
+
schedule: Schedule,
|
|
312
|
+
plan: Extract<PlanNode, {kind: 'everySecond'}>,
|
|
313
|
+
opts: Opts
|
|
314
|
+
): string {
|
|
315
|
+
return 'a cada segundo' + trailingQualifier(schedule, opts);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function renderStandaloneSeconds(
|
|
319
|
+
schedule: Schedule,
|
|
320
|
+
plan: Extract<PlanNode, {kind: 'standaloneSeconds'}>,
|
|
321
|
+
opts: Opts
|
|
322
|
+
): string {
|
|
323
|
+
return secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function renderSecondPastMinute(
|
|
327
|
+
schedule: Schedule,
|
|
328
|
+
plan: Extract<PlanNode, {kind: 'secondPastMinute'}>,
|
|
329
|
+
opts: Opts
|
|
330
|
+
): string {
|
|
331
|
+
return 'no segundo ' + schedule.pattern.second + ' de cada minuto' +
|
|
332
|
+
trailingQualifier(schedule, opts);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A meaningful second combined with a single specific minute (and an open
|
|
336
|
+
// hour): a single second folds into the minute anchor; a list, range, or
|
|
337
|
+
// step leads with its own clause.
|
|
338
|
+
function renderSecondsWithinMinute(
|
|
339
|
+
schedule: Schedule,
|
|
340
|
+
plan: Extract<PlanNode, {kind: 'secondsWithinMinute'}>,
|
|
341
|
+
opts: Opts
|
|
342
|
+
): string {
|
|
343
|
+
const minuteField = schedule.pattern.minute;
|
|
344
|
+
|
|
345
|
+
if (plan.singleSecond) {
|
|
346
|
+
return 'no minuto ' + minuteField + ' e no segundo ' +
|
|
347
|
+
schedule.pattern.second + ' de cada hora' +
|
|
348
|
+
trailingQualifier(schedule, opts);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return secondsLeadClause(schedule, opts) + ', no minuto ' + minuteField +
|
|
352
|
+
' de cada hora' + trailingQualifier(schedule, opts);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// A seconds list nested into one or more fixed clock times ("..., nos
|
|
356
|
+
// segundos 5 e 30 das 09:00 e 17:00"). An offset/uneven second step the core
|
|
357
|
+
// enumerated to this list reads as a stride cadence; otherwise the fires are
|
|
358
|
+
// listed. The clock time follows with the genitive "de" (fused to "das"), so
|
|
359
|
+
// the stride drops its "de cada minuto" anchor.
|
|
360
|
+
function secondsListAtClock(
|
|
361
|
+
schedule: Schedule,
|
|
362
|
+
rest: Extract<PlanNode, {kind: 'clockTimes'}>,
|
|
363
|
+
opts: Opts
|
|
364
|
+
): string {
|
|
365
|
+
const clockPhrases = rest.times.map(function clock(time) {
|
|
366
|
+
return atTime(timePhrase(time.hour, time.minute, null, opts));
|
|
367
|
+
});
|
|
368
|
+
const grouped = groupClockTimesByArticle(clockPhrases);
|
|
369
|
+
// Reframe the grouped "a(s)/à(s)" result to the genitive "de" form so the
|
|
370
|
+
// caller produces "das 09:00 e 17:00".
|
|
371
|
+
const clockList = degenitive(grouped);
|
|
372
|
+
const stride =
|
|
373
|
+
strideFromSegments(segmentsOf(schedule, 'second'), 'segundo', '', opts);
|
|
374
|
+
const secondsPhrase = stride ?? 'nos segundos ' +
|
|
375
|
+
joinList(segmentWords(segmentsOf(schedule, 'second')));
|
|
376
|
+
const dayFrame = trailingQualifier(schedule, opts);
|
|
377
|
+
|
|
378
|
+
return (dayFrame ? dayFrame.trimStart() + ', ' : '') +
|
|
379
|
+
secondsPhrase + ' ' + clockList;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// The hour-cadence rendering of a compose-seconds plan whose clock-time rest
|
|
383
|
+
// would cross-multiply an hour stride under a single pinned minute, or null
|
|
384
|
+
// when that does not apply (a non-clock rest, a multi-valued minute, or an
|
|
385
|
+
// hour that is not a stride).
|
|
386
|
+
function composeHourCadence(
|
|
387
|
+
schedule: Schedule,
|
|
388
|
+
plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
|
|
389
|
+
opts: Opts
|
|
390
|
+
): string | null {
|
|
391
|
+
const clockRest = plan.rest.kind === 'clockTimes' ||
|
|
392
|
+
plan.rest.kind === 'compactClockTimes';
|
|
393
|
+
|
|
394
|
+
if (!clockRest || schedule.shapes.minute !== 'single') {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const minute = +schedule.pattern.minute;
|
|
399
|
+
|
|
400
|
+
return hourCadence(schedule, minute, opts) ??
|
|
401
|
+
hourRangeCadence(schedule, minute, opts);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// A wildcard or stepped second with a fixed minute across one or more specific
|
|
405
|
+
// hours: the seconds confine to the clock time(s), each minute named.
|
|
406
|
+
function isPinnedMinuteSeconds(
|
|
407
|
+
schedule: Schedule,
|
|
408
|
+
plan: Extract<PlanNode, {kind: 'composeSeconds'}>
|
|
409
|
+
): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
|
|
410
|
+
{rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
|
|
411
|
+
return plan.rest.kind === 'clockTimes' &&
|
|
412
|
+
(schedule.shapes.second === 'wildcard' ||
|
|
413
|
+
schedule.shapes.second === 'step');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function renderComposeSeconds(
|
|
417
|
+
schedule: Schedule,
|
|
418
|
+
plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
|
|
419
|
+
opts: Opts
|
|
420
|
+
): string {
|
|
421
|
+
// An hour step (or arithmetic-progression hour list) under a single pinned
|
|
422
|
+
// minute is a cadence, not a wall of clock times: the second/minute lead,
|
|
423
|
+
// then the hour cadence ("no segundo 30 de cada hora, a cada duas horas").
|
|
424
|
+
// The clock-time rest would otherwise cross-multiply the hours.
|
|
425
|
+
const hourCad = composeHourCadence(schedule, plan, opts);
|
|
426
|
+
|
|
427
|
+
if (hourCad !== null) {
|
|
428
|
+
return hourCad;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// A wildcard or stepped second with the minute pinned to a single value
|
|
432
|
+
// across one or more specific hours: the seconds confine to the clock time.
|
|
433
|
+
if (isPinnedMinuteSeconds(schedule, plan)) {
|
|
434
|
+
return pinnedMinuteSeconds(schedule, plan.rest, opts);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Seconds list + fixed clock time: nest the seconds into the clock time(s)
|
|
438
|
+
// with genitive "das HH:MM" instead of "de cada minuto"; the minute is
|
|
439
|
+
// fixed so "de cada minuto" is misleading. Single seconds already fold into
|
|
440
|
+
// the time in the clockTimes renderer; step seconds keep their own clause.
|
|
441
|
+
if (plan.rest.kind === 'clockTimes' && schedule.shapes.second === 'list') {
|
|
442
|
+
return secondsListAtClock(schedule, plan.rest, opts);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Second-step + fixed minute + hour range + weekday: anchor the cadence to
|
|
446
|
+
// the minute after the weekday + hour-range frame.
|
|
447
|
+
if (plan.rest.kind === 'hourRange' && schedule.shapes.second === 'step' &&
|
|
448
|
+
schedule.pattern.weekday !== '*') {
|
|
449
|
+
const restNode = plan.rest;
|
|
450
|
+
const window = hourWindow(boundedWindow(restNode), opts);
|
|
451
|
+
const dayFrame = weekdayQualifier(schedule) + monthScope(schedule);
|
|
452
|
+
const cadence = 'a cada ' +
|
|
453
|
+
numero(stepSegment(schedule, 'second').interval, opts) +
|
|
454
|
+
' segundos do minuto ' + schedule.pattern.minute;
|
|
455
|
+
|
|
456
|
+
return dayFrame + ', ' + window + ', ' + cadence;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// A wildcard second under a minute */2 with a wildcard hour juxtaposes two
|
|
460
|
+
// cadences that read as contradictory ("a cada segundo, a cada dois
|
|
461
|
+
// minutos"). Bind them with the genitive "de" ("a cada segundo de cada dois
|
|
462
|
+
// minutos"), mirroring English. The rest renders "a cada dois minutos"; the
|
|
463
|
+
// genitive "de" absorbs its leading "a", giving "de cada dois minutos".
|
|
464
|
+
// Other strides, a restricted hour, and an hour cadence keep the juxtaposed
|
|
465
|
+
// form.
|
|
466
|
+
if (isEveryOtherMinuteSeconds(schedule, plan)) {
|
|
467
|
+
const rest = render(schedule, plan.rest, opts).replace(/^a /u, '');
|
|
468
|
+
|
|
469
|
+
return secondsLeadClause(schedule, opts) + ' de ' + rest;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// A compact clock-time rest folds a meaningful SINGLE second into its own
|
|
473
|
+
// leading clause, so the composer must not prepend a second lead that would
|
|
474
|
+
// double it. A wildcard or stepped second is not folded there (no
|
|
475
|
+
// clockSecond), so it still leads its own clause here.
|
|
476
|
+
const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
|
|
477
|
+
schedule.analyses.clockSecond;
|
|
478
|
+
const lead = restOwnsLead ? '' : secondsLeadClause(schedule, opts) + ', ';
|
|
479
|
+
|
|
480
|
+
return lead + render(schedule, plan.rest, opts);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// A wildcard second over an unoffset minute */2 with a wildcard hour: the two
|
|
484
|
+
// cadences read as contradictory side by side, so they bind into one.
|
|
485
|
+
function isEveryOtherMinuteSeconds(
|
|
486
|
+
schedule: Schedule,
|
|
487
|
+
plan: Extract<PlanNode, {kind: 'composeSeconds'}>
|
|
488
|
+
): boolean {
|
|
489
|
+
if (plan.rest.kind !== 'minuteFrequency' ||
|
|
490
|
+
schedule.shapes.second !== 'wildcard' ||
|
|
491
|
+
schedule.shapes.hour !== 'wildcard') {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const minuteStep = stepSegment(schedule, 'minute');
|
|
496
|
+
|
|
497
|
+
return minuteStep.startToken === '*' && minuteStep.interval === 2;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// A wildcard or stepped second under a single pinned minute and specific
|
|
501
|
+
// hour(s). The clock-time rest folds the minute into the hour, and on the
|
|
502
|
+
// 12-hour clock a pinned minute-0 drops the :00 entirely ("às 9 da manhã") —
|
|
503
|
+
// and even "às 9" reads aloud as the whole hour, hiding the one-minute
|
|
504
|
+
// confinement (60 fires in :00, not 3,600 across the hour). Minute 0 is the
|
|
505
|
+
// one-minute window at the top of each named hour: a duration frame ("durante
|
|
506
|
+
// um minuto às 9") states the confinement outright, with the hour as a bare
|
|
507
|
+
// hour so it cannot be heard as the whole hour. A non-zero pinned minute is an
|
|
508
|
+
// unambiguous clock time, so the genitive "das 09:05" form reads it as the
|
|
509
|
+
// minute, never the hour.
|
|
510
|
+
function pinnedMinuteSeconds(
|
|
511
|
+
schedule: Schedule,
|
|
512
|
+
rest: Extract<PlanNode, {kind: 'clockTimes'}>,
|
|
513
|
+
opts: Opts
|
|
514
|
+
): string {
|
|
515
|
+
// The day qualifier trails after a comma here, so the weekday reads the
|
|
516
|
+
// plural recurrence ("às segundas-feiras"), never the leading "toda X" head.
|
|
517
|
+
const dayTrail = trailingDayClause(schedule, opts);
|
|
518
|
+
const trail = dayTrail ? ', ' + dayTrail : '';
|
|
519
|
+
|
|
520
|
+
// The "durante um minuto às 9" duration form drops the clock minute, so it
|
|
521
|
+
// is correct only when the minute is a SINGLE 0 — every clock time at :00. A
|
|
522
|
+
// minute LIST whose first value is 0 (e.g. */45 → :00, :45) must name each
|
|
523
|
+
// minute, never collapse to the bare hour, so it takes the explicit clock
|
|
524
|
+
// list.
|
|
525
|
+
if (+rest.times[0].minute === 0 && schedule.shapes.minute === 'single') {
|
|
526
|
+
return secondsLeadClause(schedule, opts) + ' durante um minuto ' +
|
|
527
|
+
durationHourList(rest.times, opts) + trail;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return secondsLeadClause(schedule, opts) + ' ' +
|
|
531
|
+
explicitClockList(rest.times, opts) + trail;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// The leading clause describing a second field relative to the minute.
|
|
535
|
+
function secondsLeadClause(schedule: Schedule, opts: Opts): string {
|
|
536
|
+
return secondsClause(schedule, 'minuto', opts);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// The second clause counted against an arbitrary anchor. The anchor is
|
|
540
|
+
// "minuto" in the standalone seconds path; the hour-cadence path folds a
|
|
541
|
+
// pinned minute 0 into the hour and counts the second "de cada hora" instead
|
|
542
|
+
// ("no segundo 30 de cada hora"), so the minute-0 confinement is stated,
|
|
543
|
+
// not dropped.
|
|
544
|
+
function secondsClause(schedule: Schedule, anchor: string, opts: Opts): string {
|
|
545
|
+
const secondField = schedule.pattern.second;
|
|
546
|
+
const shape = schedule.shapes.second;
|
|
547
|
+
|
|
548
|
+
if (secondField === '*') {
|
|
549
|
+
return 'a cada segundo';
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (shape === 'step') {
|
|
553
|
+
return stepCycle60(stepSegment(schedule, 'second'), 'segundo',
|
|
554
|
+
anchor, opts);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (shape === 'range') {
|
|
558
|
+
const bounds = secondField.split('-');
|
|
559
|
+
|
|
560
|
+
return 'a cada segundo do ' + bounds[0] + ' ao ' + bounds[1] +
|
|
561
|
+
' de cada ' + anchor;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (shape === 'single') {
|
|
565
|
+
return 'no segundo ' + secondField + ' de cada ' + anchor;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return strideFromSegments(segmentsOf(schedule, 'second'), 'segundo', anchor,
|
|
569
|
+
opts) ?? 'nos segundos ' +
|
|
570
|
+
joinList(segmentWords(segmentsOf(schedule, 'second'))) +
|
|
571
|
+
' de cada ' + anchor;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --- Minute renderers. ---
|
|
575
|
+
|
|
576
|
+
function renderEveryMinute(
|
|
577
|
+
schedule: Schedule,
|
|
578
|
+
plan: Extract<PlanNode, {kind: 'everyMinute'}>,
|
|
579
|
+
opts: Opts
|
|
580
|
+
): string {
|
|
581
|
+
return 'a cada minuto' + trailingQualifier(schedule, opts);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function renderSingleMinute(
|
|
585
|
+
schedule: Schedule,
|
|
586
|
+
plan: Extract<PlanNode, {kind: 'singleMinute'}>,
|
|
587
|
+
opts: Opts
|
|
588
|
+
): string {
|
|
589
|
+
return 'no minuto ' + schedule.pattern.minute + ' de cada hora' +
|
|
590
|
+
trailingQualifier(schedule, opts);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function renderRangeOfMinutes(
|
|
594
|
+
schedule: Schedule,
|
|
595
|
+
plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
|
|
596
|
+
opts: Opts
|
|
597
|
+
): string {
|
|
598
|
+
return minuteRangeLead(schedule.pattern.minute) + ' de cada hora' +
|
|
599
|
+
trailingQualifier(schedule, opts);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function renderMultipleMinutes(
|
|
603
|
+
schedule: Schedule,
|
|
604
|
+
plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
|
|
605
|
+
opts: Opts
|
|
606
|
+
): string {
|
|
607
|
+
return minutesList(schedule, opts) + trailingQualifier(schedule, opts);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// "nos minutos 5, 10 e 30 de cada hora". An offset/uneven step the core
|
|
611
|
+
// enumerated to this list reads as a stride cadence when the fires form a
|
|
612
|
+
// long-enough progression.
|
|
613
|
+
function minutesList(schedule: Schedule, opts: Opts): string {
|
|
614
|
+
return strideFromSegments(segmentsOf(schedule, 'minute'), 'minuto', 'hora',
|
|
615
|
+
opts) ?? 'nos minutos ' +
|
|
616
|
+
joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' de cada hora';
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// "a cada minuto do 0 ao 30". The standalone renderer adds "de cada hora";
|
|
620
|
+
// when an hour qualifier follows ("..., às 09:00", "..., a cada duas horas")
|
|
621
|
+
// it would contradict, so it is not baked in here.
|
|
622
|
+
function minuteRangeLead(minuteField: string): string {
|
|
623
|
+
const bounds = minuteField.split('-');
|
|
624
|
+
|
|
625
|
+
return 'a cada minuto do ' + bounds[0] + ' ao ' + bounds[1];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Whether the hour field is a single step, which pt renders as a confinement
|
|
629
|
+
// phrase rather than a window list.
|
|
630
|
+
function singleHourStep(segments: Segment[] | null): boolean {
|
|
631
|
+
return segments !== null && segments.length === 1 &&
|
|
632
|
+
segments[0].kind === 'step';
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// A single hour step as a confinement. A stride of two over the whole day
|
|
636
|
+
// reads idiomatically as the even ("as horas pares") or odd ("ímpares")
|
|
637
|
+
// hours; any other step names its active hours, which pins the schedule
|
|
638
|
+
// precisely (ordinal/colloquial forms would be imprecise here).
|
|
639
|
+
function stepHourSpan(segment: StepSegment, opts: Opts): string {
|
|
640
|
+
const bounded = segment.startToken.indexOf('-') !== -1;
|
|
641
|
+
const start = segment.startToken === '*' ? 0 : +segment.startToken;
|
|
642
|
+
|
|
643
|
+
if (segment.interval === 2 && !bounded && start <= 1) {
|
|
644
|
+
return start === 0 ?
|
|
645
|
+
'durante as horas pares' :
|
|
646
|
+
'durante as horas ímpares';
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return 'durante as horas ' + hourSpanList(segment.fires, opts);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// The active hours of a confined cadence, dialect-aware. The 24-hour clock
|
|
653
|
+
// shares one article over bare numbers ("das 14, 18, 20 e 22"). The 12-hour
|
|
654
|
+
// clock groups the hours by day period, naming each period once ("das 9 e 11
|
|
655
|
+
// da manhã e da 1, das 3 e das 5 da tarde"); noon and midnight stand alone as
|
|
656
|
+
// "do meio-dia" / "da meia-noite".
|
|
657
|
+
function hourSpanList(fires: number[], opts: Opts): string {
|
|
658
|
+
if (!opts.ampm) {
|
|
659
|
+
return 'das ' + joinList(fires.map(String));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return joinList(hourPeriodGroups(fires, opts));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// The day period a 12-hour clock appends to an hour: the AM/PM mark for the
|
|
666
|
+
// 'english' meridiem (no shipped dialect), otherwise the day-period descriptor
|
|
667
|
+
// ("da manhã").
|
|
668
|
+
function hourPeriod(hour: number, opts: Opts): string {
|
|
669
|
+
return opts.style.meridiem === 'english' ?
|
|
670
|
+
meridiemMark(hour) :
|
|
671
|
+
dayPeriod(hour, opts);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Fire hours as per-period phrases: consecutive hours sharing a day period
|
|
675
|
+
// fold under it once ("das 9 e 11 da manhã"); noon and midnight are their own
|
|
676
|
+
// markers ("do meio-dia", "da meia-noite").
|
|
677
|
+
function hourPeriodGroups(fires: number[], opts: Opts): string[] {
|
|
678
|
+
const groups: {hours: number[]; period: string}[] = [];
|
|
679
|
+
|
|
680
|
+
fires.forEach(function place(hour): void {
|
|
681
|
+
const period = hour === 0 || hour === 12 ? '' : hourPeriod(hour, opts);
|
|
682
|
+
const last = groups[groups.length - 1];
|
|
683
|
+
|
|
684
|
+
if (period !== '' && last && last.period === period) {
|
|
685
|
+
last.hours.push(hour);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
groups.push({hours: [hour], period});
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
return groups.map(function phrase(group): string {
|
|
693
|
+
if (group.period === '') {
|
|
694
|
+
return fromTime(timePhrase(group.hours[0], 0, null, opts));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return spanHours(group.hours) + ' ' + group.period;
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// The hours of one period in the genitive "de" form: "das 9 e 11" when all
|
|
702
|
+
// take the plural article (one shared "das" head), "da 1, das 4 e das 7" when
|
|
703
|
+
// a one-o'clock mixes in — each value contracts its own preposition (de+a=da,
|
|
704
|
+
// de+as=das), since "das" cannot govern the singular "1".
|
|
705
|
+
function spanHours(hours: number[]): string {
|
|
706
|
+
const display = hours.map(function twelve(hour): number {
|
|
707
|
+
return hour % 12 || 12;
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
if (display.indexOf(1) === -1) {
|
|
711
|
+
return 'das ' + joinList(display.map(String));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return joinList(display.map(function article(hour): string {
|
|
715
|
+
return withDe((hour === 1 ? 'a ' : 'as ') + hour);
|
|
716
|
+
}));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// A repeating minute step, qualified by the active hour window(s).
|
|
720
|
+
function renderMinuteFrequency(
|
|
721
|
+
schedule: Schedule,
|
|
722
|
+
plan: Extract<PlanNode, {kind: 'minuteFrequency'}>,
|
|
723
|
+
opts: Opts
|
|
724
|
+
): string {
|
|
725
|
+
let phrase = stepCycle60(stepSegment(schedule, 'minute'), 'minuto',
|
|
726
|
+
'hora', opts);
|
|
727
|
+
|
|
728
|
+
if (plan.hours.kind === 'during') {
|
|
729
|
+
// A uneven hour stride confines the minute cadence to its own bounded hour
|
|
730
|
+
// cadence ("a cada 15 minutos, a cada cinco horas das 00:00 às 20:00").
|
|
731
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
732
|
+
|
|
733
|
+
if (cadence) {
|
|
734
|
+
phrase += ', ' + cadence;
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
// An offset step (e.g. 1/2) arrives here; a single step reads as a
|
|
738
|
+
// confinement, not the verbose window list.
|
|
739
|
+
phrase += singleHourStep(schedule.analyses.segments.hour) ?
|
|
740
|
+
', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts) :
|
|
741
|
+
' ' + hourSpanFromTimes(schedule, plan.hours.times, opts);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
else if (plan.hours.kind === 'window') {
|
|
745
|
+
phrase += ' ' + hourWindow(plan.hours, opts);
|
|
746
|
+
}
|
|
747
|
+
else if (plan.hours.kind === 'step') {
|
|
748
|
+
// A clean stride is a confinement ("as horas pares", or the active-hour
|
|
749
|
+
// list), never a juxtaposed cadence ("a cada duas horas").
|
|
750
|
+
phrase += ', ' + stepHourSpan(stepSegment(schedule, 'hour'), opts);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return phrase + trailingQualifier(schedule, opts);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// "a cada minuto das 9:00 às 9:29 da manhã". A wildcard minute is the whole
|
|
757
|
+
// hour, so it reads as that hour itself ("a cada minuto da hora das 09:00")
|
|
758
|
+
// rather than a synthesized "das HH:00 às HH:59" range the source never stated;
|
|
759
|
+
// a plain range is a real window and keeps "das … às …".
|
|
760
|
+
function renderMinuteSpanInHour(
|
|
761
|
+
schedule: Schedule,
|
|
762
|
+
plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
|
|
763
|
+
opts: Opts
|
|
764
|
+
): string {
|
|
765
|
+
if (schedule.pattern.minute === '*') {
|
|
766
|
+
return 'a cada minuto da hora ' +
|
|
767
|
+
fromTime(timePhrase(plan.hour, 0, null, opts)) +
|
|
768
|
+
trailingQualifier(schedule, opts);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return 'a cada minuto ' +
|
|
772
|
+
timeRange({hour: plan.hour, minute: plan.span[0]},
|
|
773
|
+
{hour: plan.hour, minute: plan.span[1]}, opts) +
|
|
774
|
+
trailingQualifier(schedule, opts);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// A minute window under discrete hours. Portuguese re-plans the wildcard form:
|
|
778
|
+
// rather than "during the X hours", each hour reads as its own window ("das
|
|
779
|
+
// 9:00 às 9:59").
|
|
780
|
+
function renderMinutesAcrossHours(
|
|
781
|
+
schedule: Schedule,
|
|
782
|
+
plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
|
|
783
|
+
opts: Opts
|
|
784
|
+
): string {
|
|
785
|
+
// A uneven hour stride reads as a cadence, not a wall of hour columns: the
|
|
786
|
+
// minute lead, then "a cada N horas das X às Y".
|
|
787
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
788
|
+
|
|
789
|
+
if (plan.form === 'wildcard') {
|
|
790
|
+
if (cadence !== null) {
|
|
791
|
+
return 'a cada minuto, ' + cadence + trailingQualifier(schedule, opts);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (singleHourStep(schedule.analyses.segments.hour)) {
|
|
795
|
+
return 'a cada minuto, ' +
|
|
796
|
+
stepHourSpan(stepSegment(schedule, 'hour'), opts) +
|
|
797
|
+
trailingQualifier(schedule, opts);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return 'a cada minuto ' + hourSpanFromTimes(schedule, plan.times, opts) +
|
|
801
|
+
trailingQualifier(schedule, opts);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const lead = plan.form === 'range' ?
|
|
805
|
+
minuteRangeLead(schedule.pattern.minute) :
|
|
806
|
+
minutesList(schedule, opts);
|
|
807
|
+
|
|
808
|
+
if (cadence !== null) {
|
|
809
|
+
return lead + ', ' + cadence + trailingQualifier(schedule, opts);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return lead + ', ' + atHourTimes(schedule, plan.times, opts) +
|
|
813
|
+
trailingQualifier(schedule, opts);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function renderMinuteSpanAcrossHourStep(
|
|
817
|
+
schedule: Schedule,
|
|
818
|
+
plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>,
|
|
819
|
+
opts: Opts
|
|
820
|
+
): string {
|
|
821
|
+
const segment = stepSegment(schedule, 'hour');
|
|
822
|
+
// A bounded or uneven hour step reads as its endpoint-pinning cadence; an
|
|
823
|
+
// offset-clean step keeps its confinement / per-step phrasing.
|
|
824
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
825
|
+
|
|
826
|
+
// A wildcard minute (a cadence) is reached only for a clean stride (a bounded
|
|
827
|
+
// or uneven step routes through minutesAcrossHours instead) and is confined.
|
|
828
|
+
if (plan.form === 'wildcard') {
|
|
829
|
+
return 'a cada minuto, ' + stepHourSpan(segment, opts) +
|
|
830
|
+
trailingQualifier(schedule, opts);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// A minute list keeps the same cadence clause as the range; only its lead
|
|
834
|
+
// differs ("nos minutos 5 e 30 de cada hora" vs "a cada minuto do 0 ao 30").
|
|
835
|
+
const lead = plan.form === 'list' ?
|
|
836
|
+
minutesList(schedule, opts) :
|
|
837
|
+
minuteRangeLead(schedule.pattern.minute);
|
|
838
|
+
|
|
839
|
+
return lead + ', ' +
|
|
840
|
+
(cadence ?? stepHours(segment, opts)) + trailingQualifier(schedule, opts);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// --- Hour renderers. ---
|
|
844
|
+
|
|
845
|
+
function renderEveryHour(
|
|
846
|
+
schedule: Schedule,
|
|
847
|
+
plan: Extract<PlanNode, {kind: 'everyHour'}>,
|
|
848
|
+
opts: Opts
|
|
849
|
+
): string {
|
|
850
|
+
return 'a cada hora' + trailingQualifier(schedule, opts);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function renderHourRange(
|
|
854
|
+
schedule: Schedule,
|
|
855
|
+
plan: Extract<PlanNode, {kind: 'hourRange'}>,
|
|
856
|
+
opts: Opts
|
|
857
|
+
): string {
|
|
858
|
+
const window = hourWindow(boundedWindow(plan), opts);
|
|
859
|
+
|
|
860
|
+
if (plan.minuteForm === 'wildcard') {
|
|
861
|
+
return 'a cada minuto ' + window + trailingQualifier(schedule, opts);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (plan.minuteForm === 'range') {
|
|
865
|
+
return minuteRangeLead(schedule.pattern.minute) + ', ' + window +
|
|
866
|
+
trailingQualifier(schedule, opts);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// On the hour the window joins directly ("a cada hora das 9:00 às 17:00"); a
|
|
870
|
+
// discrete minute anchors its own clause first.
|
|
871
|
+
if (schedule.pattern.minute === '0') {
|
|
872
|
+
return 'a cada hora ' + window + trailingQualifier(schedule, opts);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const lead = schedule.shapes.minute === 'single' ?
|
|
876
|
+
'no minuto ' + schedule.pattern.minute + ' de cada hora' :
|
|
877
|
+
minutesList(schedule, opts);
|
|
878
|
+
|
|
879
|
+
return lead + ', ' + window + trailingQualifier(schedule, opts);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function renderHourStep(
|
|
883
|
+
schedule: Schedule,
|
|
884
|
+
plan: Extract<PlanNode, {kind: 'hourStep'}>,
|
|
885
|
+
opts: Opts
|
|
886
|
+
): string {
|
|
887
|
+
// A bounded or uneven hour step reads as its endpoint-pinning cadence ("a
|
|
888
|
+
// cada duas horas das 09:00 às 17:00"); an offset-clean step keeps its bare
|
|
889
|
+
// or "a partir de" cadence.
|
|
890
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
891
|
+
|
|
892
|
+
if (cadence !== null) {
|
|
893
|
+
return cadence + trailingQualifier(schedule, opts);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return stepHours(stepSegment(schedule, 'hour'), opts) +
|
|
897
|
+
trailingQualifier(schedule, opts);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// The hour-range plan as a window. The close lands on the top of the final
|
|
901
|
+
// hour (minute 0) unless the minute genuinely runs to the end of that hour —
|
|
902
|
+
// i.e. a wildcard minute, which fills every minute and states no separate
|
|
903
|
+
// clause. A pinned/listed/ranged minute is named in its own lead clause, so
|
|
904
|
+
// folding it into the close too would read as a span ("às 17:05") that
|
|
905
|
+
// contradicts the minute clause; the window stays bare ("às 17:00").
|
|
906
|
+
function boundedWindow(
|
|
907
|
+
plan: Extract<PlanNode, {kind: 'hourRange'}>
|
|
908
|
+
): {from: number; to: number; last: number} {
|
|
909
|
+
const last = plan.minuteForm === 'wildcard' ? plan.boundMinute ?? 0 : 0;
|
|
910
|
+
|
|
911
|
+
return {from: plan.from, last, to: plan.to};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// "das 9:00 às 17:45": a window from the top of the first hour to the minute
|
|
915
|
+
// field's last fire within the final hour.
|
|
916
|
+
function hourWindow(
|
|
917
|
+
window: {from: number; to: number; last: number},
|
|
918
|
+
opts: Opts
|
|
919
|
+
): string {
|
|
920
|
+
return timeRange({hour: window.from, minute: 0},
|
|
921
|
+
{hour: window.to, minute: window.last}, opts);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Whether BOTH the date and weekday fields are restricted (not '*'): cron
|
|
925
|
+
// fires when either condition matches, making this a date-OR-weekday union.
|
|
926
|
+
function isDateWeekdayUnion(schedule: Schedule): boolean {
|
|
927
|
+
return schedule.pattern.date !== '*' && schedule.pattern.weekday !== '*';
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// The month lead for the unified union frame, with a trailing comma appended
|
|
931
|
+
// when the lead is a heavy enumeration (≥2 non-range months).
|
|
932
|
+
// Single month → `em janeiro`; range → `de janeiro a março`;
|
|
933
|
+
// step/enumeration (≥2 flattened singles) → `em janeiro, …, e novembro,`.
|
|
934
|
+
// Wildcard month → '' (omit; frame starts with the time).
|
|
935
|
+
function unionMonthLeadFull(schedule: Schedule): string {
|
|
936
|
+
if (schedule.pattern.month === '*') {
|
|
937
|
+
return '';
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const lead = monthPhrase(schedule, monthRanged(schedule) ? 'de ' : 'em ');
|
|
941
|
+
const segments = flattenSteps(segmentsOf(schedule, 'month'));
|
|
942
|
+
const isEnumeration = !monthRanged(schedule) && segments.length >= 2;
|
|
943
|
+
|
|
944
|
+
return isEnumeration ? lead + ',' : lead;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// The DOM arm for the union frame — month-less, driven by the date shape.
|
|
948
|
+
// Quartz and open-step forms are self-contained; ranges use `do dia N ao dia M
|
|
949
|
+
// do mês`; a single date reads `no dia N` under a restricted month (month is in
|
|
950
|
+
// the lead) or `no dia N de cada mês` under a wildcard month. The 1st is the
|
|
951
|
+
// ordinal "1º".
|
|
952
|
+
function domArm(schedule: Schedule, opts: Opts): string {
|
|
953
|
+
const date = schedule.pattern.date;
|
|
954
|
+
const quartz = quartzDatePhrase(date);
|
|
955
|
+
|
|
956
|
+
if (quartz) {
|
|
957
|
+
return hasLeadingArticle(quartz) ? withEm(quartz) : quartz;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// In the union the `*/2` day-of-month is a parity predicate over the days of
|
|
961
|
+
// the month ("um dia ímpar do mês" = 1, 3, …, 31, resetting each month), not
|
|
962
|
+
// the durative "a cada dois dias do mês" the standalone form uses. A bare
|
|
963
|
+
// "a cada dois dias" would mis-imply a continuous every-other-day cadence
|
|
964
|
+
// with no monthly anchor, so the reader could not reconstruct the odd days.
|
|
965
|
+
const parity = parityDayPredicate(date);
|
|
966
|
+
|
|
967
|
+
if (parity) {
|
|
968
|
+
return 'em ' + parity;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (isOpenStep(date)) {
|
|
972
|
+
// The open-step date arm is the bare cadence "a cada N dias do mês" (the
|
|
973
|
+
// es donor returns it bare too). Its leading "a cada" is the durative
|
|
974
|
+
// "every", not an article, so it takes no preposition — wrapping it in
|
|
975
|
+
// withEm would mis-fuse it to "na cada".
|
|
976
|
+
return stepDates(date, opts);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const segments = segmentsOf(schedule, 'date');
|
|
980
|
+
|
|
981
|
+
if (segments.length === 1 && segments[0].kind === 'range') {
|
|
982
|
+
return 'do dia ' + dayOrdinal(segments[0].bounds[0]) + ' ao dia ' +
|
|
983
|
+
segments[0].bounds[1] + ' do mês';
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (segments.length === 1 && segments[0].kind === 'single') {
|
|
987
|
+
return schedule.pattern.month === '*' ?
|
|
988
|
+
'no dia ' + dayOrdinal(segments[0].value) + ' de cada mês' :
|
|
989
|
+
'no dia ' + dayOrdinal(segments[0].value);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return 'nos dias ' + joinList(dateWords(segments)) + ' do mês';
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// The DOW arm for the union frame — month-less, driven by the weekday shape.
|
|
996
|
+
// Quartz forms are self-contained; a single weekday reads the Brazilian
|
|
997
|
+
// recurrence `às [weekday]s-feiras` / `aos domingos`; all other forms use the
|
|
998
|
+
// same phrasing as the standalone weekday qualifier (range → `em qualquer dia
|
|
999
|
+
// de segunda a sexta-feira`; list/step → `às segundas, …`).
|
|
1000
|
+
function dowArm(schedule: Schedule): string {
|
|
1001
|
+
const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
|
|
1002
|
+
|
|
1003
|
+
if (quartz) {
|
|
1004
|
+
return withEm(quartz);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Weekday lists display Monday-first (Sunday last); a lone range keeps its
|
|
1008
|
+
// form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
|
|
1009
|
+
const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
|
|
1010
|
+
const allSingles = segments.every(function single(segment) {
|
|
1011
|
+
return segment.kind === 'single';
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
if (allSingles && segments.length === 1) {
|
|
1015
|
+
return recurringWeekday((segments[0] as SingleNameSegment).value);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (allSingles) {
|
|
1019
|
+
return recurringWeekdayList(segments as SingleNameSegment[]);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// A lone weekday range reads "em qualquer dia de segunda a sexta-feira" in
|
|
1023
|
+
// the union: the leading "em qualquer dia" makes it a day predicate parallel
|
|
1024
|
+
// to the date arm ("no dia 1º de cada mês ou em qualquer dia de segunda a
|
|
1025
|
+
// sexta-feira"), so the union "ou" plainly joins two independent day
|
|
1026
|
+
// conditions.
|
|
1027
|
+
if (segments.length === 1) {
|
|
1028
|
+
return 'em qualquer dia ' +
|
|
1029
|
+
weekdayRange(segments[0] as RangeNameSegment);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return mixedWeekdayList(segments);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// The `, seja <DOM> ou <DOW>` correlative suffix for the union frame.
|
|
1036
|
+
function unionSejaSuffix(schedule: Schedule, opts: Opts): string {
|
|
1037
|
+
return ', seja ' + domArm(schedule, opts) + ' ou ' + dowArm(schedule);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// "todos os dias às 9:30 e às 17:00".
|
|
1041
|
+
function renderClockTimes(
|
|
1042
|
+
schedule: Schedule,
|
|
1043
|
+
plan: Extract<PlanNode, {kind: 'clockTimes'}>,
|
|
1044
|
+
opts: Opts
|
|
1045
|
+
): string {
|
|
1046
|
+
// An hour step or range (or arithmetic-progression hour list) under a single
|
|
1047
|
+
// pinned minute reads as a cadence or window rather than a cross-product of
|
|
1048
|
+
// clock times.
|
|
1049
|
+
if (schedule.shapes.minute === 'single') {
|
|
1050
|
+
const minute = +schedule.pattern.minute;
|
|
1051
|
+
const cadence = hourCadence(schedule, minute, opts) ??
|
|
1052
|
+
hourRangeCadence(schedule, minute, opts);
|
|
1053
|
+
|
|
1054
|
+
if (cadence !== null) {
|
|
1055
|
+
return cadence;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const phrases = plan.times.map(function clock(time) {
|
|
1060
|
+
return atTime(timePhrase(time.hour, time.minute, time.second, opts));
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
return leadingQualifier(schedule, opts) + groupClockTimes(phrases);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// The genitive clock-time list for a minute-0 compose-seconds confinement:
|
|
1067
|
+
// each time with its minute forced visible ("as 09:00"), grouped as usual,
|
|
1068
|
+
// then reframed from "a(s) …" to the genitive "de(s) …": "das 09:00", never
|
|
1069
|
+
// the bare hour.
|
|
1070
|
+
function explicitClockList(
|
|
1071
|
+
times: {hour: number; minute: number; second?: number | null}[],
|
|
1072
|
+
opts: Opts
|
|
1073
|
+
): string {
|
|
1074
|
+
const phrases = times.map(function clock(time) {
|
|
1075
|
+
return atTime(explicitTimePhrase(time.hour, time.minute, opts));
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
return degenitive(groupClockTimes(phrases));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// The bare-hour list for a minute-0 duration confinement, keeping the "à(s) …"
|
|
1082
|
+
// frame the caller embeds after "durante um minuto": "às 9", "à meia-noite",
|
|
1083
|
+
// "às 9, 10, 11 e 12". The hour reads as a bare hour (no minutes), since the
|
|
1084
|
+
// "durante um minuto" frame already carries the one-minute window — never
|
|
1085
|
+
// "às 09:00", which would read as the whole hour.
|
|
1086
|
+
function durationHourList(
|
|
1087
|
+
times: {hour: number; minute: number; second?: number | null}[],
|
|
1088
|
+
opts: Opts
|
|
1089
|
+
): string {
|
|
1090
|
+
const phrases = times.map(function clock(time) {
|
|
1091
|
+
return atTime(bareHourPhrase(time.hour, opts));
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
return groupClockTimes(phrases);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// A bare hour with its (bare) article, no minutes: "as 9" / "a 1" /
|
|
1098
|
+
// "meio-dia" / "meia-noite" on the 24-hour clock, or the 12-hour day-period
|
|
1099
|
+
// form ("as 9 da manhã"). The caller fuses the leading preposition. Used by
|
|
1100
|
+
// the minute-0 duration frame, where the minute is already stated and the
|
|
1101
|
+
// clock minute would only mislead.
|
|
1102
|
+
function bareHourPhrase(hour: number, opts: Opts): string {
|
|
1103
|
+
if (opts.ampm) {
|
|
1104
|
+
return timePhrase(hour, 0, null, opts);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (+hour === 0) {
|
|
1108
|
+
return 'meia-noite';
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (+hour === 12) {
|
|
1112
|
+
return 'meio-dia';
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
return (+hour === 1 ? 'a ' : 'as ') + hour;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// A clock time with its minute forced visible and the noon/midnight words
|
|
1119
|
+
// suppressed: "as 09:00", "as 9:00 da manhã", "as 12:00 da tarde". So a pinned
|
|
1120
|
+
// minute-0 confinement always shows its ":00". Returns the bare-article form;
|
|
1121
|
+
// the caller fuses the preposition.
|
|
1122
|
+
function explicitTimePhrase(hour: number, minute: number, opts: Opts): string {
|
|
1123
|
+
if (!opts.ampm) {
|
|
1124
|
+
const article = +hour === 1 ? 'a ' : 'as ';
|
|
1125
|
+
const suffix = opts.style.hSuffix ? ' h' : '';
|
|
1126
|
+
|
|
1127
|
+
return article +
|
|
1128
|
+
clockDigits({hour, minute, second: 0},
|
|
1129
|
+
{pad: true, sep: opts.style.sep}) + suffix;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const display = hour % 12 || 12;
|
|
1133
|
+
const time = (display === 1 ? 'a ' : 'as ') +
|
|
1134
|
+
clockDigits({hour: display, minute, second: 0}, {sep: opts.style.sep});
|
|
1135
|
+
const period = opts.style.meridiem === 'english' ?
|
|
1136
|
+
meridiemMark(hour) :
|
|
1137
|
+
dayPeriod(hour, opts);
|
|
1138
|
+
|
|
1139
|
+
return time + ' ' + period;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Group a chronological run of "à(s) …" clock phrases. The 12-hour clock
|
|
1143
|
+
// carries day periods ("da <period>"), which group chronologically by period;
|
|
1144
|
+
// the 24-hour clock has none, so it falls through to article-grouping.
|
|
1145
|
+
function groupClockTimes(phrases: string[]): string {
|
|
1146
|
+
if (phrases.length < 2) {
|
|
1147
|
+
return joinList(phrases);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
return phrases.some(carriesDayPeriod) ?
|
|
1151
|
+
groupClockTimesByDayPeriod(phrases) :
|
|
1152
|
+
groupClockTimesByArticle(phrases);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Whether a clock phrase carries a 12-hour day period ("às 9 da manhã");
|
|
1156
|
+
// 24-hour phrases ("às 09:00") never do.
|
|
1157
|
+
function carriesDayPeriod(phrase: string): boolean {
|
|
1158
|
+
return phrase.includes(' da ');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// One parsed 12-hour clock clause. A period clause keeps its (bare) article,
|
|
1162
|
+
// its (chronological) values, and the day period named once;
|
|
1163
|
+
// meio-dia/meia-noite are special clauses carried verbatim.
|
|
1164
|
+
type PeriodValue = {article: 'a' | 'as'; value: string};
|
|
1165
|
+
type ClockClause =
|
|
1166
|
+
| {kind: 'period'; period: string; values: PeriodValue[]}
|
|
1167
|
+
| {kind: 'special'; text: string};
|
|
1168
|
+
|
|
1169
|
+
// Parse one "à(s) <value> da <period>" phrase into its parts. The article is
|
|
1170
|
+
// recovered from the contracted "à"/"às" head.
|
|
1171
|
+
const periodPhrasePattern = /^(às|à) (.+) (da .+)$/u;
|
|
1172
|
+
|
|
1173
|
+
// Group 12-hour clock phrases by day period, chronologically, never
|
|
1174
|
+
// reordering. Consecutive times in the same period fold into one clause that
|
|
1175
|
+
// names the period once; the article is shared when all values agree on it and
|
|
1176
|
+
// repeated per value otherwise. Two consecutive single-value clauses that
|
|
1177
|
+
// share a value elide to "à(s) <value> da <p1> e da <p2>". Clauses join in
|
|
1178
|
+
// order. (pt drops the es RAE coma ante "y"; the join is always a plain "e".)
|
|
1179
|
+
function groupClockTimesByDayPeriod(phrases: string[]): string {
|
|
1180
|
+
const runs = collectPeriodRuns(phrases);
|
|
1181
|
+
const elided = elideSharedSingleValues(runs);
|
|
1182
|
+
const rendered = elided.map(renderPeriodRun);
|
|
1183
|
+
|
|
1184
|
+
return joinPeriodClauses(rendered);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Fold the chronological phrases into period runs: consecutive period clauses
|
|
1188
|
+
// sharing a day period merge their values; specials break a run and stand
|
|
1189
|
+
// alone.
|
|
1190
|
+
function collectPeriodRuns(phrases: string[]): ClockClause[] {
|
|
1191
|
+
const runs: ClockClause[] = [];
|
|
1192
|
+
|
|
1193
|
+
phrases.forEach(function place(phrase): void {
|
|
1194
|
+
const match = periodPhrasePattern.exec(phrase);
|
|
1195
|
+
|
|
1196
|
+
if (!match) {
|
|
1197
|
+
runs.push({kind: 'special', text: phrase});
|
|
1198
|
+
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const article = match[1] === 'às' ? 'as' : 'a';
|
|
1203
|
+
const value = match[2];
|
|
1204
|
+
const period = match[3];
|
|
1205
|
+
const last = runs[runs.length - 1];
|
|
1206
|
+
|
|
1207
|
+
if (last && last.kind === 'period' && last.period === period) {
|
|
1208
|
+
last.values.push({article, value});
|
|
1209
|
+
}
|
|
1210
|
+
else {
|
|
1211
|
+
runs.push({kind: 'period', period, values: [{article, value}]});
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
return runs;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// One rendered clause plus whether it carries an internal " e " (a multi-value
|
|
1219
|
+
// run or an elided clause). pt has no coma ante "e", so the flag is no longer
|
|
1220
|
+
// load-bearing for punctuation, but kept so the join logic mirrors the donor.
|
|
1221
|
+
type RenderedClause = {text: string; hasInternalE: boolean};
|
|
1222
|
+
|
|
1223
|
+
// Render one period run as "à(s) <value> da <period>", factoring the period
|
|
1224
|
+
// once. A shared article is named once (the contracted head); a mixed article
|
|
1225
|
+
// (a one-o'clock among others) repeats "à(s)" per value.
|
|
1226
|
+
function renderPeriodRun(clause: ClockClause): RenderedClause {
|
|
1227
|
+
if (clause.kind === 'special') {
|
|
1228
|
+
return {text: clause.text, hasInternalE: false};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const {period, values} = clause;
|
|
1232
|
+
|
|
1233
|
+
if (values.length === 1) {
|
|
1234
|
+
const tail = elidedTail(clause);
|
|
1235
|
+
|
|
1236
|
+
return {
|
|
1237
|
+
hasInternalE: tail !== '',
|
|
1238
|
+
text: withA(values[0].article + ' ' + values[0].value) + ' ' + period +
|
|
1239
|
+
tail
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const sharedArticle = values.every(function same(entry): boolean {
|
|
1244
|
+
return entry.article === values[0].article;
|
|
1245
|
+
});
|
|
1246
|
+
const parts = sharedArticle ?
|
|
1247
|
+
values.map(function bare(entry): string {
|
|
1248
|
+
return entry.value;
|
|
1249
|
+
}) :
|
|
1250
|
+
values.map(function articled(entry): string {
|
|
1251
|
+
return withA(entry.article + ' ' + entry.value);
|
|
1252
|
+
});
|
|
1253
|
+
const lead = sharedArticle ?
|
|
1254
|
+
withA(values[0].article + ' ') :
|
|
1255
|
+
'';
|
|
1256
|
+
|
|
1257
|
+
return {hasInternalE: true, text: lead + joinList(parts) + ' ' + period};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Elide two consecutive single-value clauses that share a clock value into one
|
|
1261
|
+
// clause naming each period once: "à 1 da madrugada e da tarde". Three or more
|
|
1262
|
+
// chain with repeated " e <period>". Only consecutive lone values merge; the
|
|
1263
|
+
// chronological order is never disturbed.
|
|
1264
|
+
function elideSharedSingleValues(runs: ClockClause[]): ClockClause[] {
|
|
1265
|
+
const merged: ClockClause[] = [];
|
|
1266
|
+
let i = 0;
|
|
1267
|
+
|
|
1268
|
+
while (i < runs.length) {
|
|
1269
|
+
const run = runs[i];
|
|
1270
|
+
const value = loneValue(run);
|
|
1271
|
+
let combined = run;
|
|
1272
|
+
let j = i + 1;
|
|
1273
|
+
|
|
1274
|
+
if (value !== null) {
|
|
1275
|
+
while (j < runs.length && loneValue(runs[j]) === value) {
|
|
1276
|
+
combined = appendPeriod(combined as ElidableClause,
|
|
1277
|
+
(runs[j] as ElidableClause).period);
|
|
1278
|
+
j += 1;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
merged.push(combined);
|
|
1283
|
+
i = j;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
return merged;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// A single-value period clause, the only shape the elision merges.
|
|
1290
|
+
type ElidableClause = Extract<ClockClause, {kind: 'period'}>;
|
|
1291
|
+
|
|
1292
|
+
// The lone clock value of a single-value period clause, else null.
|
|
1293
|
+
function loneValue(clause: ClockClause): string | null {
|
|
1294
|
+
return clause.kind === 'period' && clause.values.length === 1 ?
|
|
1295
|
+
clause.values[0].value :
|
|
1296
|
+
null;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Chain another period onto an elided clause: its value stays, the extra
|
|
1300
|
+
// period rides along under " e <period>".
|
|
1301
|
+
type ElidedClause = ElidableClause & {tailPeriods: string[]};
|
|
1302
|
+
|
|
1303
|
+
function appendPeriod(clause: ElidableClause, period: string): ElidedClause {
|
|
1304
|
+
const elided = clause as ElidedClause;
|
|
1305
|
+
const tailPeriods = (elided.tailPeriods || []).concat(period);
|
|
1306
|
+
|
|
1307
|
+
return {...clause, tailPeriods};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Render the elided-clause tail periods, or the empty string for a plain
|
|
1311
|
+
// clause. Reuses renderPeriodRun for the single-value head, then appends each
|
|
1312
|
+
// " e <period>".
|
|
1313
|
+
function elidedTail(clause: ClockClause): string {
|
|
1314
|
+
const tail = (clause as ElidedClause).tailPeriods;
|
|
1315
|
+
|
|
1316
|
+
if (!tail || tail.length === 0) {
|
|
1317
|
+
return '';
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
return tail.map(function chain(period): string {
|
|
1321
|
+
return ' e ' + period;
|
|
1322
|
+
}).join('');
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Join rendered period clauses in chronological order with the plain "e" join
|
|
1326
|
+
// (pt has no coma ante "e", unlike the es donor's RAE coma ante "y").
|
|
1327
|
+
function joinPeriodClauses(clauses: RenderedClause[]): string {
|
|
1328
|
+
if (clauses.length === 1) {
|
|
1329
|
+
return clauses[0].text;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const last = clauses[clauses.length - 1];
|
|
1333
|
+
const lead = clauses.slice(0, -1).map(function text(clause): string {
|
|
1334
|
+
return clause.text;
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
return lead.join(', ') + ' e ' + last.text;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// Group clock-time phrases by article (24-hour clock): à times (1-o'clock)
|
|
1341
|
+
// first, then às times, each under one prefix. All-'às' and all-'à' each
|
|
1342
|
+
// collapse to a single prefix. When the 'às' group has exactly two items the
|
|
1343
|
+
// groups join with a comma to avoid a double 'e'. The 24-hour clock has no day
|
|
1344
|
+
// periods, so every phrase is one article form. (pt has no comma before "e".)
|
|
1345
|
+
function groupClockTimesByArticle(phrases: string[]): string {
|
|
1346
|
+
const singular = 'à ';
|
|
1347
|
+
const plural = 'às ';
|
|
1348
|
+
|
|
1349
|
+
const aItems: string[] = [];
|
|
1350
|
+
const asItems: string[] = [];
|
|
1351
|
+
|
|
1352
|
+
for (const phrase of phrases) {
|
|
1353
|
+
if (phrase.startsWith(plural)) {
|
|
1354
|
+
asItems.push(phrase.slice(plural.length));
|
|
1355
|
+
}
|
|
1356
|
+
else if (phrase.startsWith(singular)) {
|
|
1357
|
+
aItems.push(phrase.slice(singular.length));
|
|
1358
|
+
}
|
|
1359
|
+
else {
|
|
1360
|
+
// Non-article phrase (ao meio-dia, à meia-noite): plain list fallback.
|
|
1361
|
+
return joinList(phrases);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// All 'às': one prefix for the whole list.
|
|
1366
|
+
if (aItems.length === 0) {
|
|
1367
|
+
return plural + joinList(asItems);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// All 'à': one shared prefix, matching the all-'às' behaviour.
|
|
1371
|
+
if (asItems.length === 0) {
|
|
1372
|
+
return singular + joinList(aItems);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Mixed: 'à' group first, then 'às' group. A plain comma — ", " — prevents a
|
|
1376
|
+
// double "e" when the join would land between two list-ending "e"s: the 'à'
|
|
1377
|
+
// group has two or more items, or the 'às' group has exactly two. Otherwise
|
|
1378
|
+
// " e " joins the two groups.
|
|
1379
|
+
const aPart = singular + joinList(aItems);
|
|
1380
|
+
const asPart = plural + joinList(asItems);
|
|
1381
|
+
const doubleE = aItems.length >= 2 || asItems.length === 2;
|
|
1382
|
+
const connector = doubleE ? ', ' : ' e ';
|
|
1383
|
+
|
|
1384
|
+
return aPart + connector + asPart;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// Compact form past the enumeration cap: a single minute folds into
|
|
1388
|
+
// per-segment hour windows; a minute list leads with its own clause.
|
|
1389
|
+
function renderCompactClockTimes(
|
|
1390
|
+
schedule: Schedule,
|
|
1391
|
+
plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
|
|
1392
|
+
opts: Opts
|
|
1393
|
+
): string {
|
|
1394
|
+
if (plan.fold) {
|
|
1395
|
+
// An hour step or range (or arithmetic-progression hour list) under the
|
|
1396
|
+
// single pinned minute reads as a cadence or window, not a wall of clock
|
|
1397
|
+
// times. (Returns null for an irregular list, which keeps folding below.)
|
|
1398
|
+
const cadence = hourCadence(schedule, plan.minute, opts) ??
|
|
1399
|
+
hourRangeCadence(schedule, plan.minute, opts);
|
|
1400
|
+
|
|
1401
|
+
if (cadence !== null) {
|
|
1402
|
+
return cadence;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const ranged = segmentsOf(schedule, 'hour').some(function range(segment) {
|
|
1406
|
+
return segment.kind === 'range';
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
// A folded contiguous hour range reads with the hourly cadence ("a cada
|
|
1410
|
+
// hora das 9:00 às 20:00 e às 22:00"), not "todos os dias".
|
|
1411
|
+
if (ranged && !schedule.analyses.clockSecond) {
|
|
1412
|
+
return 'a cada hora ' +
|
|
1413
|
+
hourSegmentTimes(
|
|
1414
|
+
schedule, plan.minute, schedule.analyses.clockSecond, opts
|
|
1415
|
+
) +
|
|
1416
|
+
trailingQualifier(schedule, opts);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
return leadingQualifier(schedule, opts) +
|
|
1420
|
+
hourSegmentTimes(
|
|
1421
|
+
schedule, plan.minute, schedule.analyses.clockSecond, opts
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// A uneven hour stride reads as a cadence after the minute lead, not a wall
|
|
1426
|
+
// of clock-time columns.
|
|
1427
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
1428
|
+
const phrase = cadence ?
|
|
1429
|
+
minutesList(schedule, opts) + ', ' + cadence +
|
|
1430
|
+
trailingQualifier(schedule, opts) :
|
|
1431
|
+
minutesList(schedule, opts) + ', ' +
|
|
1432
|
+
hourContextTimes(schedule, opts) + trailingQualifier(schedule, opts);
|
|
1433
|
+
|
|
1434
|
+
return schedule.analyses.clockSecond ?
|
|
1435
|
+
secondsLeadClause(schedule, opts) + ', ' + phrase :
|
|
1436
|
+
phrase;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// The plan dispatch table.
|
|
1440
|
+
const renderers = {
|
|
1441
|
+
clockTimes: renderClockTimes,
|
|
1442
|
+
compactClockTimes: renderCompactClockTimes,
|
|
1443
|
+
composeSeconds: renderComposeSeconds,
|
|
1444
|
+
everyHour: renderEveryHour,
|
|
1445
|
+
everyMinute: renderEveryMinute,
|
|
1446
|
+
everySecond: renderEverySecond,
|
|
1447
|
+
hourRange: renderHourRange,
|
|
1448
|
+
hourStep: renderHourStep,
|
|
1449
|
+
minuteFrequency: renderMinuteFrequency,
|
|
1450
|
+
minuteSpanAcrossHourStep: renderMinuteSpanAcrossHourStep,
|
|
1451
|
+
minuteSpanInHour: renderMinuteSpanInHour,
|
|
1452
|
+
minutesAcrossHours: renderMinutesAcrossHours,
|
|
1453
|
+
multipleMinutes: renderMultipleMinutes,
|
|
1454
|
+
rangeOfMinutes: renderRangeOfMinutes,
|
|
1455
|
+
secondPastMinute: renderSecondPastMinute,
|
|
1456
|
+
secondsWithinMinute: renderSecondsWithinMinute,
|
|
1457
|
+
singleMinute: renderSingleMinute,
|
|
1458
|
+
standaloneSeconds: renderStandaloneSeconds
|
|
1459
|
+
};
|
|
1460
|
+
|
|
1461
|
+
// --- Step phrases. ---
|
|
1462
|
+
|
|
1463
|
+
// Speak a step cadence over a `cycle`-long field (60 for minute/second). A
|
|
1464
|
+
// clean stride from the top of the cycle is the bare cadence ("a cada 15
|
|
1465
|
+
// minutos"); a uniform offset (start within the first interval, the interval
|
|
1466
|
+
// still dividing the cycle) names only its start, since it wraps cleanly with
|
|
1467
|
+
// no distinct endpoint ("a cada seis minutos a partir do minuto 5 de cada
|
|
1468
|
+
// hora"); a non-uniform stride (start >= interval, or an interval that does
|
|
1469
|
+
// not divide the cycle) pins both endpoints so the bounded, non-wrapping set
|
|
1470
|
+
// reads unambiguously ("a cada dois minutos do minuto 3 ao 59 de cada hora").
|
|
1471
|
+
// This is the one phrasing for every step the renderer speaks, whether the
|
|
1472
|
+
// core kept it a step shape (a clean cadence) or enumerated it to a fire list
|
|
1473
|
+
// (an offset/uneven set the list path recognizes as a progression).
|
|
1474
|
+
function renderStride(stride: Stride, opts: Opts): string {
|
|
1475
|
+
const {interval, start, last, cycle, unit, anchor} = stride;
|
|
1476
|
+
const cadence = 'a cada ' + numero(interval, opts) + ' ' + unit + 's';
|
|
1477
|
+
|
|
1478
|
+
// A context that supplies its own trailing scope passes an empty anchor, so
|
|
1479
|
+
// the cadence keeps its endpoints but drops the "de cada <anchor>" tail.
|
|
1480
|
+
const tail = anchor ? ' de cada ' + anchor : '';
|
|
1481
|
+
|
|
1482
|
+
return chooseStride({start, interval, last, cycle}, {
|
|
1483
|
+
bare: () => cadence,
|
|
1484
|
+
offset: () => cadence + ' a partir do ' + unit + ' ' + start + tail,
|
|
1485
|
+
bounded: () =>
|
|
1486
|
+
cadence + ' do ' + unit + ' ' + start + ' ao ' + last + tail
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// "a cada 15 minutos", "nos minutos 5, 20 e 35 de cada hora", or "a cada 15
|
|
1491
|
+
// minutos a partir do minuto 5 de cada hora". A step shape only reaches here as
|
|
1492
|
+
// a clean cadence (the interval divides 60), so the stride collapses to the
|
|
1493
|
+
// bare or uniform-offset form; an offset/uneven set arrives as a fire list and
|
|
1494
|
+
// is recognized by the list path instead.
|
|
1495
|
+
function stepCycle60(
|
|
1496
|
+
segment: StepSegment,
|
|
1497
|
+
unit: string,
|
|
1498
|
+
anchor: string,
|
|
1499
|
+
opts: Opts
|
|
1500
|
+
): string {
|
|
1501
|
+
if (segment.startToken.indexOf('-') !== -1) {
|
|
1502
|
+
return 'nos ' + unit + 's ' + joinList(wordList(segment.fires)) +
|
|
1503
|
+
' de cada ' + anchor;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const start = segment.startToken === '*' ? 0 : +segment.startToken;
|
|
1507
|
+
|
|
1508
|
+
// A short offset cadence still lists its fires; the stride phrasing names
|
|
1509
|
+
// the interval and offset only once there are enough fires to beat the list.
|
|
1510
|
+
if (start !== 0 && segment.fires.length <= 3) {
|
|
1511
|
+
return 'nos ' + unit + 's ' + joinList(wordList(segment.fires)) +
|
|
1512
|
+
' de cada ' + anchor;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
return renderStride({
|
|
1516
|
+
interval: segment.interval,
|
|
1517
|
+
start,
|
|
1518
|
+
last: segment.fires[segment.fires.length - 1],
|
|
1519
|
+
cycle: 60,
|
|
1520
|
+
unit,
|
|
1521
|
+
anchor
|
|
1522
|
+
}, opts);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Speak a minute/second field's enumerated fires as a step cadence when they
|
|
1526
|
+
// form an arithmetic progression long enough to beat the list (the core
|
|
1527
|
+
// enumerates an offset/uneven step to this fire list; the Schedule is
|
|
1528
|
+
// unchanged, so the renderer recognizes the progression). Returns null for a
|
|
1529
|
+
// non-progression or a too-short list, leaving the caller to enumerate.
|
|
1530
|
+
function strideFromSegments(
|
|
1531
|
+
segments: Segment[],
|
|
1532
|
+
unit: string,
|
|
1533
|
+
anchor: string,
|
|
1534
|
+
opts: Opts
|
|
1535
|
+
): string | null {
|
|
1536
|
+
const values = singleValues(segments);
|
|
1537
|
+
const step = values && arithmeticStep(values);
|
|
1538
|
+
|
|
1539
|
+
return step ?
|
|
1540
|
+
renderStride({...step, cycle: 60, unit, anchor}, opts) :
|
|
1541
|
+
null;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
// "a cada seis horas", "às 9:00, às 11:00 e à 1:00", or "a cada cinco horas a
|
|
1546
|
+
// partir das 2:00".
|
|
1547
|
+
function stepHours(segment: StepSegment, opts: Opts): string {
|
|
1548
|
+
if (segment.startToken.indexOf('-') !== -1) {
|
|
1549
|
+
return groupClockTimesByArticle(atTimes(segment.fires, opts));
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const start = segment.startToken === '*' ? 0 : +segment.startToken;
|
|
1553
|
+
const interval = segment.interval;
|
|
1554
|
+
|
|
1555
|
+
// A clean stride from midnight is the bare cadence. (An uneven stride is
|
|
1556
|
+
// rewritten to its fires upstream and never reaches here.)
|
|
1557
|
+
if (start === 0) {
|
|
1558
|
+
return 'a cada ' + numeroF(interval, opts) + ' horas';
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (segment.fires.length <= 3) {
|
|
1562
|
+
return groupClockTimesByArticle(atTimes(segment.fires, opts));
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
return 'a cada ' + numeroF(interval, opts) + ' horas a partir ' +
|
|
1566
|
+
fromTime(timePhrase(start, 0, null, opts));
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// --- Hour-step cadence (the 24-cycle analog of renderStride). ---
|
|
1570
|
+
|
|
1571
|
+
// Speak an hour stride as a cadence with clock-time bounds: a clean stride
|
|
1572
|
+
// from midnight is the bare cadence ("a cada duas horas"); a clean offset
|
|
1573
|
+
// names only its start ("a cada seis horas a partir das 02:00"); a bounded or
|
|
1574
|
+
// non-tiling stride pins both clock-time endpoints ("a cada duas horas das
|
|
1575
|
+
// 09:00 às 17:00") so the bounded set reads unambiguously. Used wherever an
|
|
1576
|
+
// hour step (or arithmetic-progression hour list) would otherwise be
|
|
1577
|
+
// cross-multiplied into a wall of clock times.
|
|
1578
|
+
function hourStrideCadence(
|
|
1579
|
+
stride: {start: number; interval: number; last: number},
|
|
1580
|
+
opts: Opts
|
|
1581
|
+
): string {
|
|
1582
|
+
const {start, interval, last} = stride;
|
|
1583
|
+
const cadence = 'a cada ' + numeroF(interval, opts) + ' horas';
|
|
1584
|
+
|
|
1585
|
+
return chooseStride({start, interval, last, cycle: 24}, {
|
|
1586
|
+
bare: () => cadence,
|
|
1587
|
+
offset: () => cadence + ' a partir ' +
|
|
1588
|
+
fromTime(timePhrase(start, 0, null, opts)),
|
|
1589
|
+
bounded: () => cadence + ' ' +
|
|
1590
|
+
fromTime(timePhrase(start, 0, null, opts)) + ' ' +
|
|
1591
|
+
toTime(timePhrase(last, 0, null, opts))
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// The bounded cadence for an hour stride that pins both clock-time endpoints,
|
|
1596
|
+
// or null when the hour is not such a stride. The core rewrites a uneven step
|
|
1597
|
+
// to its fire list, so a minute window/list/step crossed with it lands in the
|
|
1598
|
+
// enumerating list paths; there the bounded hour reads better as its cadence
|
|
1599
|
+
// ("…, a cada cinco horas das 00:00 às 20:00") than as a wall of clock times.
|
|
1600
|
+
// An offset-clean stride keeps its existing confinement form, so only the
|
|
1601
|
+
// endpoint-bearing case routes here.
|
|
1602
|
+
function unevenHourCadence(schedule: Schedule, opts: Opts): string | null {
|
|
1603
|
+
const stride = hourStride(schedule);
|
|
1604
|
+
|
|
1605
|
+
if (!stride || offsetCleanStride(stride)) {
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
return hourStrideCadence(stride, opts);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// The hour field's stride, or null when the hour is not a cadence: a step
|
|
1613
|
+
// segment yields its {start, interval, last} directly; an all-single hour
|
|
1614
|
+
// list yields one only when its values form a step progression (so an irregular
|
|
1615
|
+
// list like 9,17 keeps enumerating). The Schedule is unchanged — the renderer
|
|
1616
|
+
// recognizes the stride and speaks it as a cadence instead of the clock-time
|
|
1617
|
+
// cross-product.
|
|
1618
|
+
function hourStride(
|
|
1619
|
+
schedule: Schedule
|
|
1620
|
+
): {start: number; interval: number; last: number} | null {
|
|
1621
|
+
const segments = segmentsOf(schedule, 'hour');
|
|
1622
|
+
|
|
1623
|
+
if (segments.length === 1 && segments[0].kind === 'step') {
|
|
1624
|
+
const segment = segments[0];
|
|
1625
|
+
|
|
1626
|
+
// A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
|
|
1627
|
+
// value, not a stride: it has no interval to speak and no endpoint to pin.
|
|
1628
|
+
if (segment.fires.length < 2) {
|
|
1629
|
+
return null;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
const start = segment.startToken === '*' ?
|
|
1633
|
+
0 :
|
|
1634
|
+
+segment.startToken.split('-')[0];
|
|
1635
|
+
|
|
1636
|
+
return {interval: segment.interval, last: segment.fires[
|
|
1637
|
+
segment.fires.length - 1], start};
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
const values = singleValues(segments);
|
|
1641
|
+
|
|
1642
|
+
return values && hourListStride(values);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// The second's status against a pinned minute: a wildcard or sub-minute step
|
|
1646
|
+
// fills the minute (a "durante um minuto" frame at minute 0); a single 0 is
|
|
1647
|
+
// just the top of the minute (no clause); anything else needs its own clause.
|
|
1648
|
+
function subMinuteSecond(schedule: Schedule): boolean {
|
|
1649
|
+
return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// The lead clause for an hour-cadence rendering: the second and the pinned
|
|
1653
|
+
// minute, before the hour cadence. A pinned minute 0 folds in — a single,
|
|
1654
|
+
// list, or range second is counted "de cada hora" (the minute-0 is the top of
|
|
1655
|
+
// the hour), and a wildcard or sub-minute step second takes a "durante um
|
|
1656
|
+
// minuto" frame (the whole minute-0 window). A non-zero minute is a real clock
|
|
1657
|
+
// minute: the second leads with its own clause (if any), then the minute reads
|
|
1658
|
+
// "no minuto M".
|
|
1659
|
+
function hourCadenceLead(
|
|
1660
|
+
schedule: Schedule, minute: number, opts: Opts
|
|
1661
|
+
): string {
|
|
1662
|
+
if (minute === 0) {
|
|
1663
|
+
if (subMinuteSecond(schedule)) {
|
|
1664
|
+
return secondsClause(schedule, 'minuto', opts) + ' durante um minuto';
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
return secondsClause(schedule, 'hora', opts);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
const minutePhrase = 'no minuto ' + minute;
|
|
1671
|
+
|
|
1672
|
+
// A single 0 second is just the top of the minute, so the minute leads
|
|
1673
|
+
// alone; any other second prefixes its own clause.
|
|
1674
|
+
if (schedule.pattern.second === '0') {
|
|
1675
|
+
return minutePhrase;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
return secondsClause(schedule, 'minuto', opts) + ', ' + minutePhrase;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Render an hour step (or arithmetic-progression hour list) under a single
|
|
1682
|
+
// pinned minute and a second as a cadence — the lead clause, then the hour
|
|
1683
|
+
// cadence — instead of cross-multiplying the hours into a wall of clock times.
|
|
1684
|
+
// Returns null when the hour is not a stride (an irregular list, a single
|
|
1685
|
+
// hour, or a range), or when the cross-product is short enough that
|
|
1686
|
+
// enumeration is no longer than the cadence: a meaningful second makes every
|
|
1687
|
+
// clock time three digit-groups, so any stride is worth compacting; otherwise
|
|
1688
|
+
// the stride must exceed the clock-time cap, the same point at which the core
|
|
1689
|
+
// itself stops enumerating. Renderer-only; the Schedule is unchanged.
|
|
1690
|
+
function hourCadence(
|
|
1691
|
+
schedule: Schedule, minute: number, opts: Opts
|
|
1692
|
+
): string | null {
|
|
1693
|
+
const stride = hourStride(schedule);
|
|
1694
|
+
|
|
1695
|
+
if (!stride) {
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const fires = (stride.last - stride.start) / stride.interval + 1;
|
|
1700
|
+
|
|
1701
|
+
// A short stride that spells out as few clock times stays an enumeration only
|
|
1702
|
+
// when it wraps cleanly (an offset-clean stride with no endpoint): the bare
|
|
1703
|
+
// or "a partir de" form is no shorter than the list. A bounded or uneven
|
|
1704
|
+
// stride has no clean wrap, so its endpoint-pinning cadence ("a cada cinco
|
|
1705
|
+
// horas das 00:00 às 20:00") reads better however short.
|
|
1706
|
+
if (schedule.pattern.second === '0' && fires <= maxClockTimes &&
|
|
1707
|
+
offsetCleanStride(stride)) {
|
|
1708
|
+
return null;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// A wildcard or sub-minute step second confined to minute 0 of a clean hour
|
|
1712
|
+
// stride is a confinement, not a juxtaposed cadence: it reads "durante um
|
|
1713
|
+
// minuto, durante as horas pares", reusing the hour-step confinement idiom
|
|
1714
|
+
// so the minute-0 window is never heard as the bare hour cadence.
|
|
1715
|
+
const confinement = minute === 0 && subMinuteSecond(schedule) &&
|
|
1716
|
+
cleanStrideSegment(schedule);
|
|
1717
|
+
|
|
1718
|
+
if (confinement) {
|
|
1719
|
+
return secondsClause(schedule, 'minuto', opts) + ' durante um minuto, ' +
|
|
1720
|
+
stepHourSpan(confinement, opts) + trailingQualifier(schedule, opts);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
|
|
1724
|
+
// lead clause to fold in, so the bounded cadence stands on its own ("a cada
|
|
1725
|
+
// cinco horas das 00:00 às 20:00").
|
|
1726
|
+
if (minute === 0 && schedule.pattern.second === '0') {
|
|
1727
|
+
return hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
return hourCadenceLead(schedule, minute, opts) + ', ' +
|
|
1731
|
+
hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// The hour step segment when the hour is a clean stride pt renders as a
|
|
1735
|
+
// confinement phrase ("durante as horas pares"); null otherwise (an offset or
|
|
1736
|
+
// bounded step, an uneven stride, or an arithmetic-progression list, which
|
|
1737
|
+
// keep the bounded cadence form).
|
|
1738
|
+
function cleanStrideSegment(schedule: Schedule): StepSegment | null {
|
|
1739
|
+
const segments = segmentsOf(schedule, 'hour');
|
|
1740
|
+
const segment = segments.length === 1 && segments[0];
|
|
1741
|
+
|
|
1742
|
+
if (!segment || segment.kind !== 'step' ||
|
|
1743
|
+
segment.startToken.indexOf('-') !== -1) {
|
|
1744
|
+
return null;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
return segment;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Whether the hour field is a range — or a list whose segments include a
|
|
1751
|
+
// range — and so forms a window rather than a cross-product of clock times.
|
|
1752
|
+
// A pure single-value list (9,17) has no range to span and still enumerates;
|
|
1753
|
+
// a step is handled by hourStride/hourCadence.
|
|
1754
|
+
function hasHourWindow(schedule: Schedule): boolean {
|
|
1755
|
+
return segmentsOf(schedule, 'hour').some(function range(segment) {
|
|
1756
|
+
return segment.kind === 'range';
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// Render an hour range (or a list whose segments include a range) under
|
|
1761
|
+
// minute 0 and a meaningful second as the hour-range window — the lead clause,
|
|
1762
|
+
// then "das 09:00 às 17:00" (and any non-contiguous hour joined with "e
|
|
1763
|
+
// também") — instead of cross-multiplying the hours into a wall of clock
|
|
1764
|
+
// times. The hour-RANGE analog of hourCadence. Returns null when the hour has
|
|
1765
|
+
// no range, when the minute is non-zero (a real clock minute the existing
|
|
1766
|
+
// window form already speaks), or when a plain :00 set carries no clause.
|
|
1767
|
+
// Renderer-only; the Schedule is unchanged.
|
|
1768
|
+
function hourRangeCadence(
|
|
1769
|
+
schedule: Schedule, minute: number, opts: Opts
|
|
1770
|
+
): string | null {
|
|
1771
|
+
if (minute !== 0 || !hasHourWindow(schedule) ||
|
|
1772
|
+
schedule.pattern.second === '0') {
|
|
1773
|
+
return null;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// A wildcard or sub-minute step second confined to minute 0 is the whole
|
|
1777
|
+
// minute-0 window ("durante um minuto"), confined to the hour range with the
|
|
1778
|
+
// "durante as horas …" idiom — kept distinct from the bare minute-0 window
|
|
1779
|
+
// ("a cada hora das 09:00 às 17:00") so the confinement is never heard as it
|
|
1780
|
+
// — the hour-range analog of "durante um minuto, durante as horas pares".
|
|
1781
|
+
if (subMinuteSecond(schedule)) {
|
|
1782
|
+
return secondsClause(schedule, 'minuto', opts) + ' durante um minuto, ' +
|
|
1783
|
+
'durante as horas ' + hourSegmentTimes(schedule, 0, null, opts) +
|
|
1784
|
+
trailingQualifier(schedule, opts);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
return hourCadenceLead(schedule, minute, opts) + ', ' +
|
|
1788
|
+
hourSegmentTimes(schedule, 0, null, opts) +
|
|
1789
|
+
trailingQualifier(schedule, opts);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// --- Hour-time phrasing. ---
|
|
1793
|
+
|
|
1794
|
+
// The fixed hour(s) of a stepped/listed minute, named as the HOUR rather than a
|
|
1795
|
+
// "às HH:00" clock instant the minute never fires at: noon and midnight read
|
|
1796
|
+
// as the hour word ("ao meio-dia"/"à meia-noite"), any other hour as the whole
|
|
1797
|
+
// hour "da hora das HH:00" (the idiom a wildcard minute already uses). Used by
|
|
1798
|
+
// the compact-clock non-fold path, where the minute is a step or list (a
|
|
1799
|
+
// single-value minute keeps its real "às HH:MM" clock time elsewhere).
|
|
1800
|
+
function hourContextTimes(schedule: Schedule, opts: Opts): string {
|
|
1801
|
+
const segments = segmentsOf(schedule, 'hour');
|
|
1802
|
+
|
|
1803
|
+
// Collect the point hours (singles and step fires) — a range stays a window.
|
|
1804
|
+
const points: number[] = [];
|
|
1805
|
+
const hasRange = segments.some(function range(segment) {
|
|
1806
|
+
return segment.kind === 'range';
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
segments.forEach(function collect(segment) {
|
|
1810
|
+
if (segment.kind === 'step') {
|
|
1811
|
+
points.push(...segment.fires);
|
|
1812
|
+
}
|
|
1813
|
+
else if (segment.kind === 'single') {
|
|
1814
|
+
points.push(+segment.value);
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
// All point hours, all noon/midnight: stand alone as their own words ("à
|
|
1819
|
+
// meia-noite e ao meio-dia").
|
|
1820
|
+
function isWord(hour: number): boolean {
|
|
1821
|
+
return !opts.ampm && (hour === 0 || hour === 12);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
if (!hasRange && points.every(isWord)) {
|
|
1825
|
+
return joinList(points.map(function each(hour) {
|
|
1826
|
+
return atTime(bareHourPhrase(hour, opts));
|
|
1827
|
+
}));
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// A point hour as the whole hour: "da hora das HH:00".
|
|
1831
|
+
function wholeHour(hour: number): string {
|
|
1832
|
+
return 'da hora ' + fromTime(explicitTimePhrase(hour, 0, opts));
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// Otherwise each whole hour reads as a window ("das HH:00 às HH:00" for a
|
|
1836
|
+
// range, "da hora das HH:00" for a point), never a false "às HH:00" clock
|
|
1837
|
+
// instant the stepped minute never fires at.
|
|
1838
|
+
const pieces: string[] = [];
|
|
1839
|
+
|
|
1840
|
+
segments.forEach(function place(segment) {
|
|
1841
|
+
if (segment.kind === 'range') {
|
|
1842
|
+
pieces.push(timeRange(
|
|
1843
|
+
{hour: +segment.bounds[0], minute: 0},
|
|
1844
|
+
{hour: +segment.bounds[1], minute: 0}, opts));
|
|
1845
|
+
}
|
|
1846
|
+
else if (segment.kind === 'step') {
|
|
1847
|
+
segment.fires.forEach(function each(hour) {
|
|
1848
|
+
pieces.push(wholeHour(hour));
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
else {
|
|
1852
|
+
pieces.push(wholeHour(+segment.value));
|
|
1853
|
+
}
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
return joinList(pieces);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// "às 9:00" / "à 1:00" / "ao meio-dia" for each fire hour.
|
|
1860
|
+
function atTimes(hours: number[], opts: Opts): string[] {
|
|
1861
|
+
return hours.map(function each(hour) {
|
|
1862
|
+
return atTime(timePhrase(hour, 0, null, opts));
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// The hour times accompanying a lead clause: "às 9:00 e às 17:00", with long
|
|
1867
|
+
// expansions rendered segment by segment.
|
|
1868
|
+
function atHourTimes(
|
|
1869
|
+
schedule: Schedule,
|
|
1870
|
+
times: HourTimesPlan,
|
|
1871
|
+
opts: Opts
|
|
1872
|
+
): string {
|
|
1873
|
+
if (times.kind === 'fires') {
|
|
1874
|
+
return groupClockTimesByArticle(atTimes(times.fires, opts));
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
return hourSegmentTimes(schedule, 0, null, opts);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// The active hours of a confined cadence: a few hours read as windows; many
|
|
1881
|
+
// read better as a compact list ("durante as horas das 9, 11, 13, 15 e 17")
|
|
1882
|
+
// than as a sprawl of windows.
|
|
1883
|
+
function hourSpanFromTimes(
|
|
1884
|
+
schedule: Schedule, times: HourTimesPlan, opts: Opts
|
|
1885
|
+
): string {
|
|
1886
|
+
if (times.kind === 'fires' && times.fires.length > 3) {
|
|
1887
|
+
return 'durante as horas ' + hourSpanList(times.fires, opts);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
return hourWindowsFromTimes(schedule, times, opts);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Each fire hour as its own one-hour window: "das 9:00 às 9:59 e das 17:00 às
|
|
1894
|
+
// 17:59". Portuguese prefers this to the English "during the 9 a.m. and 5 p.m.
|
|
1895
|
+
// hours" shape.
|
|
1896
|
+
function hourWindowsFromTimes(
|
|
1897
|
+
schedule: Schedule,
|
|
1898
|
+
times: HourTimesPlan,
|
|
1899
|
+
opts: Opts
|
|
1900
|
+
): string {
|
|
1901
|
+
if (times.kind === 'fires') {
|
|
1902
|
+
return joinList(times.fires.map(function window(hour) {
|
|
1903
|
+
return hourAsWindow(hour, opts);
|
|
1904
|
+
}));
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
return joinList(segmentsOf(schedule, 'hour').map(function window(segment) {
|
|
1908
|
+
if (segment.kind === 'range') {
|
|
1909
|
+
return timeRange({hour: +segment.bounds[0], minute: 0},
|
|
1910
|
+
{hour: +segment.bounds[1], minute: 59}, opts);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
if (segment.kind === 'step') {
|
|
1914
|
+
return joinList(segment.fires.map(function each(hour) {
|
|
1915
|
+
return hourAsWindow(hour, opts);
|
|
1916
|
+
}));
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
return hourAsWindow(+segment.value, opts);
|
|
1920
|
+
}));
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// Clock times for the hour field rendered segment by segment, the minute
|
|
1924
|
+
// (and optional second) folded into each: "das 9:30 às 20:30 e também às
|
|
1925
|
+
// 22:30" when an isolated point-time follows a range.
|
|
1926
|
+
function hourSegmentTimes(
|
|
1927
|
+
schedule: Schedule,
|
|
1928
|
+
minute: number,
|
|
1929
|
+
second: number | null | undefined,
|
|
1930
|
+
opts: Opts
|
|
1931
|
+
): string {
|
|
1932
|
+
// Track whether each piece came from a range (true) or a point (false).
|
|
1933
|
+
const pieces: string[] = [];
|
|
1934
|
+
const fromRange: boolean[] = [];
|
|
1935
|
+
|
|
1936
|
+
segmentsOf(schedule, 'hour').forEach(function clock(segment) {
|
|
1937
|
+
if (segment.kind === 'step') {
|
|
1938
|
+
segment.fires.forEach(function each(hour) {
|
|
1939
|
+
pieces.push(atTime(timePhrase(hour, minute, second, opts)));
|
|
1940
|
+
fromRange.push(false);
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
else if (segment.kind === 'range') {
|
|
1944
|
+
pieces.push(timeRange(
|
|
1945
|
+
{hour: +segment.bounds[0], minute, second},
|
|
1946
|
+
{hour: +segment.bounds[1], minute, second}, opts));
|
|
1947
|
+
fromRange.push(true);
|
|
1948
|
+
}
|
|
1949
|
+
else {
|
|
1950
|
+
pieces.push(atTime(timePhrase(+segment.value, minute, second, opts)));
|
|
1951
|
+
fromRange.push(false);
|
|
1952
|
+
}
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
// When the last piece is an isolated point-time that follows a range, join it
|
|
1956
|
+
// with "e também" so it is not read as the range extending.
|
|
1957
|
+
const lastIdx = pieces.length - 1;
|
|
1958
|
+
const hasRange = fromRange.some(function ranged(r) {
|
|
1959
|
+
return r;
|
|
1960
|
+
});
|
|
1961
|
+
const lastIsPoint = lastIdx >= 1 && !fromRange[lastIdx] &&
|
|
1962
|
+
fromRange[lastIdx - 1];
|
|
1963
|
+
|
|
1964
|
+
if (hasRange && lastIsPoint) {
|
|
1965
|
+
return joinList(pieces.slice(0, lastIdx)) + ' e também ' + pieces[lastIdx];
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
return groupClockTimesByArticle(pieces);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// --- Times. ---
|
|
1972
|
+
|
|
1973
|
+
// A time range, "das 9:00 às 5:45 da tarde", between two `{hour, minute,
|
|
1974
|
+
// second}` ends. When both ends share a day period it is said once, at the end.
|
|
1975
|
+
function timeRange(
|
|
1976
|
+
from: ClockEnd,
|
|
1977
|
+
to: ClockEnd,
|
|
1978
|
+
opts: Opts
|
|
1979
|
+
): string {
|
|
1980
|
+
const fromPhrase = timePhrase(from.hour, from.minute, from.second, opts);
|
|
1981
|
+
const toPhrase = timePhrase(to.hour, to.minute, to.second, opts);
|
|
1982
|
+
const fromPeriod = dayPeriod(from.hour, opts);
|
|
1983
|
+
const toPeriod = dayPeriod(to.hour, opts);
|
|
1984
|
+
|
|
1985
|
+
if (fromPeriod && fromPeriod === toPeriod &&
|
|
1986
|
+
fromPhrase.endsWith(fromPeriod)) {
|
|
1987
|
+
return fromTime(stripPeriod(fromPhrase, fromPeriod)) + ' ' +
|
|
1988
|
+
toTime(toPhrase);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
return fromTime(fromPhrase) + ' ' + toTime(toPhrase);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// A one-hour window, "das 9:00 às 9:59".
|
|
1995
|
+
function hourAsWindow(hour: number, opts: Opts): string {
|
|
1996
|
+
return timeRange({hour, minute: 0}, {hour, minute: 59}, opts);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// Drop a shared day period from the first end of a range.
|
|
2000
|
+
function stripPeriod(phrase: string, period: string): string {
|
|
2001
|
+
return phrase.slice(0, -(period.length + 1));
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// "às 9:30" / "à 1:00" / "ao meio-dia" / "à meia-noite". The phrase carries a
|
|
2005
|
+
// bare article, so `a` fuses with it (a+as=às, a+a=à, a+o=ao).
|
|
2006
|
+
function atTime(phrase: string): string {
|
|
2007
|
+
return withA(phrase);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// "das 9:30" / "do meio-dia" / "da meia-noite". `de` fuses with the bare
|
|
2011
|
+
// article (de+as=das, de+a=da, de+o=do).
|
|
2012
|
+
function fromTime(phrase: string): string {
|
|
2013
|
+
return withDe(phrase);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// "às 17:45" as the closing end of a range.
|
|
2017
|
+
function toTime(phrase: string): string {
|
|
2018
|
+
return atTime(phrase);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Reframe an "à(s) …" grouped clock list to the genitive "de(s) …" form by
|
|
2022
|
+
// re-contracting the leading preposition: "às 09:00" -> "das 09:00", "à 01:00"
|
|
2023
|
+
// -> "da 01:00". The grouping already factored the article into the head, so
|
|
2024
|
+
// only that head is rewritten.
|
|
2025
|
+
function degenitive(grouped: string): string {
|
|
2026
|
+
if (grouped.startsWith('às ')) {
|
|
2027
|
+
return 'das ' + grouped.slice(3);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
if (grouped.startsWith('à ')) {
|
|
2031
|
+
return 'da ' + grouped.slice(2);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
return grouped;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// A clock time with its article: "as 9:30 da manhã", "a 1 da tarde",
|
|
2038
|
+
// "meio-dia", or "as 17:45" in 24-hour mode. The article is bare so the
|
|
2039
|
+
// preposition contracts at the "at"/"from" boundary. On-the-hour times drop
|
|
2040
|
+
// their minutes; exact 12:00 reads as a word.
|
|
2041
|
+
function timePhrase(
|
|
2042
|
+
hour: number,
|
|
2043
|
+
minute: number,
|
|
2044
|
+
second: number | null | undefined,
|
|
2045
|
+
opts: Opts
|
|
2046
|
+
): string {
|
|
2047
|
+
const showSeconds = typeof second === 'number' && second > 0 ? second : 0;
|
|
2048
|
+
|
|
2049
|
+
if (!opts.ampm) {
|
|
2050
|
+
// One o'clock takes the singular article ("a 01:00") even on the 24-hour
|
|
2051
|
+
// clock; every other hour is plural ("as 13:00"). Hours are zero-padded to
|
|
2052
|
+
// two digits, like the minutes.
|
|
2053
|
+
const article = +hour === 1 ? 'a ' : 'as ';
|
|
2054
|
+
const suffix = opts.style.hSuffix ? ' h' : '';
|
|
2055
|
+
|
|
2056
|
+
return article +
|
|
2057
|
+
clockDigits({hour, minute, second: showSeconds},
|
|
2058
|
+
{pad: true, sep: opts.style.sep}) + suffix;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
return twelveHourPhrase(hour, minute, showSeconds, opts);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// The 12-hour phrase with its (bare) article and day period.
|
|
2065
|
+
function twelveHourPhrase(
|
|
2066
|
+
hour: number,
|
|
2067
|
+
minute: number,
|
|
2068
|
+
second: number,
|
|
2069
|
+
opts: Opts
|
|
2070
|
+
): string {
|
|
2071
|
+
if (+minute === 0 && !second) {
|
|
2072
|
+
if (+hour === 0) {
|
|
2073
|
+
return 'meia-noite';
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
if (+hour === 12) {
|
|
2077
|
+
return 'meio-dia';
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
const display = hour % 12 || 12;
|
|
2082
|
+
const time = (display === 1 ? 'a ' : 'as ') +
|
|
2083
|
+
clockDigits({hour: display, minute, second},
|
|
2084
|
+
{lean: true, sep: opts.style.sep});
|
|
2085
|
+
|
|
2086
|
+
const period = opts.style.meridiem === 'english'
|
|
2087
|
+
? meridiemMark(hour)
|
|
2088
|
+
: dayPeriod(hour, opts);
|
|
2089
|
+
|
|
2090
|
+
return time + ' ' + period;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// The English meridiem mark: "AM" before noon, "PM" from noon. No shipped pt
|
|
2094
|
+
// dialect uses it; kept for parity with the donor scaffold.
|
|
2095
|
+
function meridiemMark(hour: number): string {
|
|
2096
|
+
return +hour < 12 ? 'AM' : 'PM';
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// The Portuguese day period for an hour: "da madrugada" (1-5), "da manhã"
|
|
2100
|
+
// (6-11), "da tarde" (12-18), or "da noite" (19-23 and midnight's hour). The
|
|
2101
|
+
// pt-BR panel unanimously ratified the noite boundary at 19h (the
|
|
2102
|
+
// broadcast/weather register and the "jornal da noite" anchor; see notes.md),
|
|
2103
|
+
// earlier than the es donor's 20h: 18h reads "da tarde", 19h+ "da noite" (e.g.
|
|
2104
|
+
// "1/3" → "7 da noite" at 19h). Empty in 24-hour mode.
|
|
2105
|
+
function dayPeriod(hour: number, opts: Opts): string {
|
|
2106
|
+
if (!opts.ampm) {
|
|
2107
|
+
return '';
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
if (+hour === 0 || +hour >= 19) {
|
|
2111
|
+
return 'da noite';
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
if (+hour <= 5) {
|
|
2115
|
+
return 'da madrugada';
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
if (+hour <= 11) {
|
|
2119
|
+
return 'da manhã';
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
return 'da tarde';
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// --- Day-level qualifiers. ---
|
|
2126
|
+
|
|
2127
|
+
// The qualifier that precedes clock times: "todos os dias ", "toda
|
|
2128
|
+
// segunda-feira ", "no dia 13 de cada mês ", "de segunda a sexta-feira ".
|
|
2129
|
+
// Date-OR-weekday unions skip this entirely — the unified frame in `render`
|
|
2130
|
+
// handles the month lead and day-level suffix.
|
|
2131
|
+
function leadingQualifier(schedule: Schedule, opts: Opts): string {
|
|
2132
|
+
const pattern = schedule.pattern;
|
|
2133
|
+
|
|
2134
|
+
if (pattern.date !== '*' && pattern.weekday !== '*') {
|
|
2135
|
+
return '';
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
if (pattern.date !== '*') {
|
|
2139
|
+
return datePhrase(schedule, opts) + ' ';
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
if (pattern.weekday !== '*') {
|
|
2143
|
+
return weekdayLead(schedule) + ' ';
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
if (pattern.month !== '*') {
|
|
2147
|
+
return 'todos os dias ' + monthPhrase(schedule, 'de ') + ' ';
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
return 'todos os dias ';
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// The day qualifier for a clause that TRAILS after a comma (e.g. "…, às
|
|
2154
|
+
// segundas-feiras"). It mirrors leadingQualifier but, being non-leading, the
|
|
2155
|
+
// weekday reads the plural recurrence ("às segundas-feiras"), never the leading
|
|
2156
|
+
// "toda X" head, and the plain "todos os dias" survives where trailingQualifier
|
|
2157
|
+
// would drop it. Returns no surrounding spaces; the caller sets the comma.
|
|
2158
|
+
function trailingDayClause(schedule: Schedule, opts: Opts): string {
|
|
2159
|
+
const pattern = schedule.pattern;
|
|
2160
|
+
|
|
2161
|
+
if (pattern.date !== '*' && pattern.weekday !== '*') {
|
|
2162
|
+
return '';
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
if (pattern.date !== '*') {
|
|
2166
|
+
return datePhrase(schedule, opts);
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
if (pattern.weekday !== '*') {
|
|
2170
|
+
return weekdayQualifier(schedule) + monthScope(schedule);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
if (pattern.month !== '*') {
|
|
2174
|
+
return 'todos os dias ' + monthPhrase(schedule, 'de ');
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
return 'todos os dias';
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
// The qualifier trailing a frequency: " às segundas-feiras", " em junho", " no
|
|
2181
|
+
// dia 13 de cada mês". Empty when no day-level field is set.
|
|
2182
|
+
// Date-OR-weekday unions skip this entirely — the unified frame in `render`
|
|
2183
|
+
// handles the month lead and day-level suffix.
|
|
2184
|
+
function trailingQualifier(schedule: Schedule, opts: Opts): string {
|
|
2185
|
+
const pattern = schedule.pattern;
|
|
2186
|
+
|
|
2187
|
+
if (pattern.date !== '*' && pattern.weekday !== '*') {
|
|
2188
|
+
return '';
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
if (pattern.date !== '*') {
|
|
2192
|
+
return ' ' + datePhrase(schedule, opts);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
if (pattern.weekday !== '*') {
|
|
2196
|
+
return ' ' + weekdayQualifier(schedule) + monthScope(schedule);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
if (pattern.month !== '*') {
|
|
2200
|
+
return ' ' + monthPhrase(schedule, 'em ');
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
return '';
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// The leading weekday qualifier before a clock time. A single FEMININE weekday
|
|
2207
|
+
// (a -feira day) directly followed by a clock time reads the singular "toda X"
|
|
2208
|
+
// head (notes.md: kills the double-"às" of "às segundas-feiras às 9 …"). A
|
|
2209
|
+
// masculine single weekday (domingo/sábado) recurs as "aos domingos" (its "aos"
|
|
2210
|
+
// never clashes with the time's "à(s)") so it keeps the plural form, as does
|
|
2211
|
+
// any list/range and any weekday under a ranged month scope (a comma then sets
|
|
2212
|
+
// the time off, so there is no adjacency to clash). A month scope rides along.
|
|
2213
|
+
function weekdayLead(schedule: Schedule): string {
|
|
2214
|
+
const single = singleFeminineWeekday(schedule);
|
|
2215
|
+
|
|
2216
|
+
if (single !== null && !monthRanged(schedule)) {
|
|
2217
|
+
return everyWeekday(single) + monthScope(schedule);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
return weekdayQualifier(schedule) + monthScope(schedule);
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// The canonical weekday number when the field is exactly one plain FEMININE
|
|
2224
|
+
// weekday (a single -feira day, not Quartz), else null. The "toda X" head
|
|
2225
|
+
// applies only to this shape (its "às" recurrence is the one that clashes).
|
|
2226
|
+
function singleFeminineWeekday(schedule: Schedule): number | null {
|
|
2227
|
+
if (quartzWeekdayPhrase(schedule.pattern.weekday)) {
|
|
2228
|
+
return null;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const segments = segmentsOf(schedule, 'weekday');
|
|
2232
|
+
|
|
2233
|
+
if (segments.length === 1 && segments[0].kind === 'single') {
|
|
2234
|
+
const number = canonicalWeekday(segments[0].value);
|
|
2235
|
+
|
|
2236
|
+
return weekdayFeminine(number) ? number : null;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
return null;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// "toda segunda-feira": the singular recurrence head for a single feminine
|
|
2243
|
+
// weekday leading a clock time.
|
|
2244
|
+
function everyWeekday(number: number): string {
|
|
2245
|
+
return 'toda ' + weekdayNames[number];
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// The date qualifier: "no dia 13 de junho", "nos dias 1º e 15 de cada mês",
|
|
2249
|
+
// "do dia 1º ao dia 15 de cada mês", or a Quartz phrase. A foldable single
|
|
2250
|
+
// year joins the date ("no dia 25 de dezembro de 2030").
|
|
2251
|
+
function datePhrase(schedule: Schedule, opts: Opts): string {
|
|
2252
|
+
const pattern = schedule.pattern;
|
|
2253
|
+
|
|
2254
|
+
if (quartzDatePhrase(pattern.date) || isOpenStep(pattern.date)) {
|
|
2255
|
+
return dateClause(schedule, '', opts) + monthScope(schedule);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
return dateClause(schedule, dateMonthPart(schedule), opts);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
// The date words with a caller-chosen month part. Quartz phrases and open
|
|
2262
|
+
// steps are self-contained and ignore the month part.
|
|
2263
|
+
function dateClause(
|
|
2264
|
+
schedule: Schedule,
|
|
2265
|
+
monthPart: string,
|
|
2266
|
+
opts: Opts
|
|
2267
|
+
): string {
|
|
2268
|
+
const pattern = schedule.pattern;
|
|
2269
|
+
const quartz = quartzDatePhrase(pattern.date);
|
|
2270
|
+
|
|
2271
|
+
if (quartz) {
|
|
2272
|
+
return hasLeadingArticle(quartz) ? withEm(quartz) : quartz;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
if (isOpenStep(pattern.date)) {
|
|
2276
|
+
return stepDates(pattern.date, opts);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
const segments = segmentsOf(schedule, 'date');
|
|
2280
|
+
|
|
2281
|
+
if (segments.length === 1 && segments[0].kind === 'range') {
|
|
2282
|
+
return 'do dia ' + dayOrdinal(segments[0].bounds[0]) + ' ao dia ' +
|
|
2283
|
+
segments[0].bounds[1] + monthPart + foldedYear(schedule);
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
if (segments.length === 1 && segments[0].kind === 'single') {
|
|
2287
|
+
return 'no dia ' + dayOrdinal(segments[0].value) + monthPart +
|
|
2288
|
+
foldedYear(schedule);
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
return 'nos dias ' + joinList(dateWords(segments)) + monthPart +
|
|
2292
|
+
foldedYear(schedule);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// Whether the month field contains a range segment.
|
|
2296
|
+
function monthRanged(schedule: Schedule): boolean {
|
|
2297
|
+
return schedule.pattern.month !== '*' &&
|
|
2298
|
+
segmentsOf(schedule, 'month').some(function range(segment) {
|
|
2299
|
+
return segment.kind === 'range';
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// The month attached to a calendar date. Single months and flat name lists
|
|
2304
|
+
// fold in ("no dia 1º de junho e dezembro"), but a range cannot — "no dia 1º
|
|
2305
|
+
// de junho a setembro" parses as "(no dia 1º de junho) a setembro" — so it
|
|
2306
|
+
// scopes the date instead ("no dia 1º de cada mês, de junho a setembro").
|
|
2307
|
+
function dateMonthPart(schedule: Schedule): string {
|
|
2308
|
+
if (schedule.pattern.month === '*') {
|
|
2309
|
+
return ' de cada mês';
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
if (monthRanged(schedule)) {
|
|
2313
|
+
return ' de cada mês, ' + monthPhrase(schedule, 'de ');
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
return ' ' + monthPhrase(schedule, 'de ');
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// "de 2030" when a single year can fold into a calendar date.
|
|
2320
|
+
function foldedYear(schedule: Schedule): string {
|
|
2321
|
+
const yearField = schedule.pattern.year;
|
|
2322
|
+
|
|
2323
|
+
if (yearField === '*' || yearField.indexOf('/') !== -1 ||
|
|
2324
|
+
yearField.indexOf('-') !== -1 || yearField.indexOf(',') !== -1) {
|
|
2325
|
+
return '';
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
return ' de ' + yearField;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// The Quartz date phrases. Each begins with a bare article so the caller can
|
|
2332
|
+
// fuse a preposition (em+o=no, de+o=do): "o último dia do mês".
|
|
2333
|
+
function quartzDatePhrase(dateField: string): string | undefined {
|
|
2334
|
+
if (dateField === 'L') {
|
|
2335
|
+
return 'o último dia do mês';
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
if (dateField === 'LW' || dateField === 'WL') {
|
|
2339
|
+
return 'o último dia útil do mês';
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
const offset = (/^L-(\d{1,2})$/).exec(dateField);
|
|
2343
|
+
|
|
2344
|
+
if (offset) {
|
|
2345
|
+
return +offset[1] === 1 ?
|
|
2346
|
+
'um dia antes do último dia do mês' :
|
|
2347
|
+
offset[1] + ' dias antes do último dia do mês';
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
const nearest = (/^(\d{1,2})W$|^W(\d{1,2})$/).exec(dateField);
|
|
2351
|
+
|
|
2352
|
+
if (nearest) {
|
|
2353
|
+
// The W-operator proximity takes the dative "próximo ao dia 15" (a+o=ao),
|
|
2354
|
+
// not "próximo do" (notes.md).
|
|
2355
|
+
return 'o dia útil mais próximo ao dia ' +
|
|
2356
|
+
(nearest[1] || nearest[2]);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// The Quartz weekday phrases: "a última sexta-feira do mês", "a segunda
|
|
2361
|
+
// segunda-feira do mês". The nth-weekday ordinal agrees with the weekday's
|
|
2362
|
+
// gender; when the ordinal WORD would collide with the weekday name (the "#2"
|
|
2363
|
+
// of Monday spelling "segunda segunda-feira"), the ordinal digit is used
|
|
2364
|
+
// instead ("a 2ª segunda-feira do mês"). Each phrase begins with a bare
|
|
2365
|
+
// article so the caller can fuse a preposition (em+a=na, em+o=no).
|
|
2366
|
+
function quartzWeekdayPhrase(weekdayField: string): string | undefined {
|
|
2367
|
+
const parts = weekdayField.split('#');
|
|
2368
|
+
|
|
2369
|
+
if (parts.length === 2) {
|
|
2370
|
+
const number = canonicalWeekday(parts[0]);
|
|
2371
|
+
const feminine = weekdayFeminine(number);
|
|
2372
|
+
const ordinalWord = (feminine ? nthWeekdayFeminine : nthWeekdayMasculine)[
|
|
2373
|
+
+parts[1]];
|
|
2374
|
+
const article = feminine ? 'a ' : 'o ';
|
|
2375
|
+
|
|
2376
|
+
// The ordinal word collides with the weekday name when it shares the stem
|
|
2377
|
+
// (the "segunda" of segunda-feira, etc.): use the ordinal digit "Nª"/"Nº".
|
|
2378
|
+
if (ordinalCollides(ordinalWord, number)) {
|
|
2379
|
+
return article + parts[1] + (feminine ? 'ª ' : 'º ') +
|
|
2380
|
+
weekdayNames[number] + ' do mês';
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
return article + ordinalWord + ' ' +
|
|
2384
|
+
weekdayNames[number] + ' do mês';
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
if ((/L$/).test(weekdayField)) {
|
|
2388
|
+
const number = canonicalWeekday(weekdayField.slice(0, -1));
|
|
2389
|
+
const article = weekdayFeminine(number) ? 'a última ' : 'o último ';
|
|
2390
|
+
|
|
2391
|
+
return article + weekdayNames[number] + ' do mês';
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// Whether an ordinal word would read as a homograph of the weekday name — i.e.
|
|
2396
|
+
// the ordinal shares the weekday's bare stem (segunda/terça/quarta/quinta).
|
|
2397
|
+
// "a segunda segunda-feira" is unreadable, so the digit form is used instead.
|
|
2398
|
+
function ordinalCollides(ordinalWord: string | null, number: number): boolean {
|
|
2399
|
+
return ordinalWord === weekdayStems[number];
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// The weekday qualifier (the trailing/standalone recurrence form): "às
|
|
2403
|
+
// segundas-feiras", "de segunda a sexta-feira", "às segundas, quartas e
|
|
2404
|
+
// sextas-feiras". The plural recurrence "às [weekday]s-feiras" already conveys
|
|
2405
|
+
// "every Monday".
|
|
2406
|
+
function weekdayQualifier(schedule: Schedule): string {
|
|
2407
|
+
const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
|
|
2408
|
+
|
|
2409
|
+
if (quartz) {
|
|
2410
|
+
return withEm(quartz);
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// Weekday lists display Monday-first (Sunday last); a lone range keeps its
|
|
2414
|
+
// form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
|
|
2415
|
+
const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
|
|
2416
|
+
const allSingles = segments.every(function single(segment) {
|
|
2417
|
+
return segment.kind === 'single';
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2420
|
+
if (allSingles) {
|
|
2421
|
+
return recurringWeekdayList(segments as SingleNameSegment[]);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
// A single plain range stands alone: "de segunda a sexta-feira". Reaching
|
|
2425
|
+
// here means not all-singles with a single segment, i.e. a lone range.
|
|
2426
|
+
if (segments.length === 1) {
|
|
2427
|
+
return weekdayRange(segments[0] as RangeNameSegment);
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// Mixed lists: each piece carries its own form.
|
|
2431
|
+
return mixedWeekdayList(segments);
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// The recurrence for a single weekday: "às segundas-feiras" (feminine, a+as=às)
|
|
2435
|
+
// / "aos domingos" (masculine, a+os=aos).
|
|
2436
|
+
function recurringWeekday(token: NameToken): string {
|
|
2437
|
+
const number = canonicalWeekday(token);
|
|
2438
|
+
const article = weekdayFeminine(number) ? 'as ' : 'os ';
|
|
2439
|
+
|
|
2440
|
+
return withA(article + pluralWeekday(token));
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// A list of single weekdays as the recurrence. The feminine -feira days lead
|
|
2444
|
+
// under one shared "às" head, with the "-feira" suffix on the LAST -feira day
|
|
2445
|
+
// only (the idiomatic pt-BR suffix-ellipsis): "às segundas, quartas e
|
|
2446
|
+
// sextas-feiras". A SINGLE masculine day trailing the feminine run takes its
|
|
2447
|
+
// own contracted article ("às …-feiras e aos domingos") — "às" cannot govern a
|
|
2448
|
+
// masculine noun — while a RUN of trailing masculine days (sábado+domingo)
|
|
2449
|
+
// stays under the shared "às" head ("às terças, quintas-feiras, sábados e
|
|
2450
|
+
// domingos"), the form the pt-BR panel affirmed. An all-masculine list takes
|
|
2451
|
+
// the masculine recurrence outright ("aos domingos").
|
|
2452
|
+
function recurringWeekdayList(segments: SingleNameSegment[]): string {
|
|
2453
|
+
const numbers = segments.map(function num(segment) {
|
|
2454
|
+
return canonicalWeekday(segment.value);
|
|
2455
|
+
});
|
|
2456
|
+
|
|
2457
|
+
const feminineCount = numbers.filter(weekdayFeminine).length;
|
|
2458
|
+
|
|
2459
|
+
// All masculine: the whole list takes the masculine recurrence.
|
|
2460
|
+
if (feminineCount === 0) {
|
|
2461
|
+
return withA('os ' + joinList(numbers.map(pluralFeira)));
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// A single masculine day trailing the feminine run splits into its own
|
|
2465
|
+
// contracted "ao(s)" group; a longer trailing run stays under "às".
|
|
2466
|
+
const trailingMasculine = numbers.length - feminineCount === 1 &&
|
|
2467
|
+
!weekdayFeminine(numbers[numbers.length - 1]);
|
|
2468
|
+
const head = trailingMasculine ? numbers.slice(0, -1) : numbers;
|
|
2469
|
+
const tail = trailingMasculine ? numbers[numbers.length - 1] : null;
|
|
2470
|
+
|
|
2471
|
+
if (tail === null) {
|
|
2472
|
+
return withA('as ' + joinList(feiraEllipsis(head)));
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// The feminine head joins with commas only — the terminal "e" connects it to
|
|
2476
|
+
// the split masculine tail: "às segundas, quartas, sextas-feiras e aos
|
|
2477
|
+
// domingos".
|
|
2478
|
+
const feminineList = withA('as ' + feiraEllipsis(head).join(', '));
|
|
2479
|
+
|
|
2480
|
+
return feminineList + ' e ' + withA('os ' + pluralFeira(tail));
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
// The plural weekday words for a list, with the "-feira" suffix kept on the
|
|
2484
|
+
// LAST -feira day only and elided from the earlier ones (the pt-BR
|
|
2485
|
+
// suffix-ellipsis); masculine days carry their full plural.
|
|
2486
|
+
function feiraEllipsis(numbers: number[]): string[] {
|
|
2487
|
+
let lastFeira = -1;
|
|
2488
|
+
|
|
2489
|
+
numbers.forEach(function find(number, index) {
|
|
2490
|
+
if (weekdayFeminine(number)) {
|
|
2491
|
+
lastFeira = index;
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
return numbers.map(function word(number, index) {
|
|
2496
|
+
if (weekdayFeminine(number)) {
|
|
2497
|
+
return index === lastFeira ? pluralFeira(number) : pluralStem(number);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
return pluralFeira(number);
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// A mixed weekday list (ranges + singles), each piece carrying its own form:
|
|
2505
|
+
// ranges read "de X a Y-feira", singles read the recurrence "às Xs-feiras" /
|
|
2506
|
+
// "aos domingos". Used in the standalone qualifier and the OR-union dow arm.
|
|
2507
|
+
function mixedWeekdayList(segments: NameSegment[]): string {
|
|
2508
|
+
return joinList(segments.map(function name(segment) {
|
|
2509
|
+
return segment.kind === 'range' ?
|
|
2510
|
+
weekdayRange(segment) :
|
|
2511
|
+
recurringWeekday(segment.value);
|
|
2512
|
+
}));
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// "de segunda a sexta-feira": the range carries the "-feira" on the LAST term
|
|
2516
|
+
// only (the idiomatic pt-BR range shorthand), and the bare stem on the first.
|
|
2517
|
+
function weekdayRange(segment: RangeNameSegment): string {
|
|
2518
|
+
return 'de ' + weekdayStem(segment.bounds[0]) + ' a ' +
|
|
2519
|
+
weekdayName(segment.bounds[1]);
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// Expand step segments into their fires as singles: a raw step token or a
|
|
2523
|
+
// nested sub-list garbles a name list, while the flat fires read naturally
|
|
2524
|
+
// ("às segundas, quartas e sextas-feiras").
|
|
2525
|
+
function flattenSteps(segments: Segment[]): NameSegment[] {
|
|
2526
|
+
return segments.flatMap(function flat(segment): NameSegment[] {
|
|
2527
|
+
return segment.kind === 'step' ?
|
|
2528
|
+
segment.fires.map(function single(value): NameSegment {
|
|
2529
|
+
return {kind: 'single', value};
|
|
2530
|
+
}) :
|
|
2531
|
+
[segment];
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// The month qualifier with its preposition. Plain name lists distribute the
|
|
2536
|
+
// caller's preposition ("de junho e dezembro", "em janeiro e julho"); step
|
|
2537
|
+
// segments flatten into their fires. A range always reads "de X a Y" as one
|
|
2538
|
+
// unit, so in mixed lists every piece repeats its preposition ("em janeiro e
|
|
2539
|
+
// de março a junho") — a bare "janeiro e março a junho" parses as "(janeiro e
|
|
2540
|
+
// março) a junho".
|
|
2541
|
+
function monthPhrase(schedule: Schedule, lead: string): string {
|
|
2542
|
+
const segments = flattenSteps(segmentsOf(schedule, 'month'));
|
|
2543
|
+
const ranged = segments.some(function range(segment) {
|
|
2544
|
+
return segment.kind === 'range';
|
|
2545
|
+
});
|
|
2546
|
+
|
|
2547
|
+
if (!ranged) {
|
|
2548
|
+
// No ranges remain, so every segment is a single with a `value`.
|
|
2549
|
+
return lead + joinList(segments.map(function name(segment) {
|
|
2550
|
+
return monthName((segment as SingleNameSegment).value);
|
|
2551
|
+
}));
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
return joinList(segments.map(function name(segment) {
|
|
2555
|
+
if (segment.kind === 'range') {
|
|
2556
|
+
return 'de ' + monthName(segment.bounds[0]) + ' a ' +
|
|
2557
|
+
monthName(segment.bounds[1]);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
return lead + monthName(segment.value);
|
|
2561
|
+
}));
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// A trailing " de <month>" scope on weekday qualifiers ("às segundas-feiras de
|
|
2565
|
+
// junho"). A ranged scope sets off with a comma ("o último dia do mês, de
|
|
2566
|
+
// junho a setembro") — gluing "de junho" after "do mês" garden-paths.
|
|
2567
|
+
function monthScope(schedule: Schedule): string {
|
|
2568
|
+
if (schedule.pattern.month === '*') {
|
|
2569
|
+
return '';
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
return (monthRanged(schedule) ? ', ' : ' ') + monthPhrase(schedule, 'de ');
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
// The parity predicate for a `*/2`-style day-of-month step, used only inside
|
|
2576
|
+
// the OR union frame (see domArm). `*/2` and `1/2` fire on the odd days
|
|
2577
|
+
// (1, 3, …, 31); `2/2` fires on the even days. Any other open step has no
|
|
2578
|
+
// parity reading, so the caller falls back to stepDates.
|
|
2579
|
+
function parityDayPredicate(dateField: string): string | undefined {
|
|
2580
|
+
if (!isOpenStep(dateField)) {
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
const [start, step] = dateField.split('/');
|
|
2585
|
+
|
|
2586
|
+
if (+step !== 2) {
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
if (start === '*' || start === '1') {
|
|
2591
|
+
return 'um dia ímpar do mês';
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
if (start === '2') {
|
|
2595
|
+
return 'um dia par do mês';
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
// Open day-of-month steps: "a cada 2 dias do mês (a partir do dia 5)". Begins
|
|
2600
|
+
// with a bare lead the caller may fuse where needed.
|
|
2601
|
+
function stepDates(dateField: string, opts: Opts): string {
|
|
2602
|
+
const parts = dateField.split('/');
|
|
2603
|
+
let phrase = 'a cada ' + numero(+parts[1], opts) + ' dias do mês';
|
|
2604
|
+
|
|
2605
|
+
if (parts[0] !== '*' && parts[0] !== '1') {
|
|
2606
|
+
phrase += ' a partir do dia ' + dayOrdinal(parts[0]);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
return phrase;
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// --- Years. ---
|
|
2613
|
+
|
|
2614
|
+
// Append the year when it has not folded into a calendar date: "em 2030", "em
|
|
2615
|
+
// 2030, 2031 e 2032", "a cada dois anos a partir de 2030".
|
|
2616
|
+
function applyYear(
|
|
2617
|
+
description: string,
|
|
2618
|
+
schedule: Schedule,
|
|
2619
|
+
opts: Opts
|
|
2620
|
+
): string {
|
|
2621
|
+
const yearField = schedule.pattern.year;
|
|
2622
|
+
|
|
2623
|
+
if (yearField === '*') {
|
|
2624
|
+
return description;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
if (yearField.indexOf('/') !== -1) {
|
|
2628
|
+
return description + ' ' + stepYears(yearField, opts);
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// A foldable single year already joined its date in datePhrase.
|
|
2632
|
+
if (foldedYear(schedule) && schedule.pattern.date !== '*') {
|
|
2633
|
+
return description;
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
if (yearField.indexOf(',') !== -1) {
|
|
2637
|
+
return description + ' em ' + joinList(yearField.split(','));
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
return description + ' em ' + yearField;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// "a cada dois anos (a partir de 2030)" / "todos os anos".
|
|
2644
|
+
function stepYears(yearField: string, opts: Opts): string {
|
|
2645
|
+
const parts = yearField.split('/');
|
|
2646
|
+
const interval = +parts[1];
|
|
2647
|
+
|
|
2648
|
+
if (interval <= 1) {
|
|
2649
|
+
return 'todos os anos';
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
let phrase = 'a cada ' + numero(interval, opts) + ' anos';
|
|
2653
|
+
|
|
2654
|
+
if (parts[0] !== '*' && parts[0] !== '0') {
|
|
2655
|
+
phrase += ' a partir de ' + parts[0];
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
return phrase;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// --- Words. ---
|
|
2662
|
+
|
|
2663
|
+
// Render classified segments as words: ranges as "5 a 10" pairs, steps as
|
|
2664
|
+
// their enumerated fires.
|
|
2665
|
+
function segmentWords(segments: Segment[]): string[] {
|
|
2666
|
+
return segments.flatMap(function word(segment) {
|
|
2667
|
+
if (segment.kind === 'range') {
|
|
2668
|
+
return [segment.bounds[0] + ' a ' + segment.bounds[1]];
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
if (segment.kind === 'step') {
|
|
2672
|
+
return wordList(segment.fires);
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
return [segment.value];
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
// Render date segments as words, with the 1st of the month as the ordinal
|
|
2680
|
+
// "1º" and every other day cardinal. Ranges carry the ordinal on the first
|
|
2681
|
+
// term and cardinal on the rest (the normal pt-BR pattern).
|
|
2682
|
+
function dateWords(segments: Segment[]): string[] {
|
|
2683
|
+
return segments.flatMap(function word(segment) {
|
|
2684
|
+
if (segment.kind === 'range') {
|
|
2685
|
+
return [dayOrdinal(segment.bounds[0]) + ' a ' + segment.bounds[1]];
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
if (segment.kind === 'step') {
|
|
2689
|
+
return segment.fires.map(function fire(value, index) {
|
|
2690
|
+
return index === 0 ? dayOrdinal(value) : '' + value;
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
return [dayOrdinal(segment.value)];
|
|
2695
|
+
});
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// The day-of-month value as words: the 1st is the ordinal "1º" (a deep pt-BR
|
|
2699
|
+
// norm — calendars, official texts, speech); every other day stays cardinal.
|
|
2700
|
+
function dayOrdinal(value: NameToken): string {
|
|
2701
|
+
return +value === 1 ? '1º' : '' + value;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// Numeric fire values as digits.
|
|
2705
|
+
function wordList(fires: number[]): string[] {
|
|
2706
|
+
return fires.map(function digit(value) {
|
|
2707
|
+
return '' + value;
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// Join a list with commas and a terminal "e". Portuguese never takes a comma
|
|
2712
|
+
// before "e" in a simple series (the es donor's RAE coma ante "y" is dropped).
|
|
2713
|
+
function joinList(items: string[]): string {
|
|
2714
|
+
if (items.length <= 1) {
|
|
2715
|
+
return items.join('');
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
if (items.length === 2) {
|
|
2719
|
+
return items[0] + ' e ' + items[1];
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
return items.slice(0, -1).join(', ') + ' e ' + items[items.length - 1];
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// Spell the integers zero through ten ("a cada cinco minutos"); digits
|
|
2726
|
+
// otherwise, and always with `short`. Masculine by default (minutos, segundos,
|
|
2727
|
+
// dias, anos).
|
|
2728
|
+
function numero(n: number, opts: Opts): string | number {
|
|
2729
|
+
return numeral(n, numeros, opts);
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
// The feminine spelling for a count of feminine nouns ("a cada duas horas"):
|
|
2733
|
+
// "dois" -> "duas" is the only gendered cardinal in the 0-10 set pt spells.
|
|
2734
|
+
function numeroF(n: number, opts: Opts): string | number {
|
|
2735
|
+
const word = numero(n, opts);
|
|
2736
|
+
|
|
2737
|
+
return word === 'dois' ? 'duas' : word;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
// The canonical weekday number (Sunday=0) from a cron name or Quartz stem
|
|
2741
|
+
// (`5L`, `MON#2`), folding the Sunday alias 7 to 0.
|
|
2742
|
+
function canonicalWeekday(token: NameToken): number {
|
|
2743
|
+
const number = toFieldNumber('' + token, weekdayNumbers);
|
|
2744
|
+
|
|
2745
|
+
return number === 7 ? 0 : number;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// A weekday name (with "-feira" for Mon-Fri) from a canonical number or Quartz
|
|
2749
|
+
// stem.
|
|
2750
|
+
function weekdayName(token: NameToken): string {
|
|
2751
|
+
return weekdayNames[canonicalWeekday(token)];
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// A weekday bare stem (no "-feira") from a canonical number or Quartz stem,
|
|
2755
|
+
// for list/range suffix-ellipsis.
|
|
2756
|
+
function weekdayStem(token: NameToken): string {
|
|
2757
|
+
return weekdayStems[canonicalWeekday(token)];
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// The plural weekday form (with "-feira"): the -feira days are invariant in
|
|
2761
|
+
// the stem and pluralize the "feira" element ("segundas-feiras"); sábado and
|
|
2762
|
+
// domingo take -s ("sábados", "domingos").
|
|
2763
|
+
function pluralWeekday(token: NameToken): string {
|
|
2764
|
+
return pluralFeira(canonicalWeekday(token));
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
// The plural full form for a weekday number: "segundas-feiras" for the -feira
|
|
2768
|
+
// days, "domingos"/"sábados" otherwise.
|
|
2769
|
+
function pluralFeira(number: number): string {
|
|
2770
|
+
if (weekdayFeminine(number)) {
|
|
2771
|
+
return pluralStem(number) + '-feiras';
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
return weekdayNames[number] + 's';
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// The plural bare stem for a -feira weekday ("segundas", "quartas"): the stem
|
|
2778
|
+
// pluralizes (all -feira stems end in -a), the dropped "-feira" is supplied (or
|
|
2779
|
+
// elided) by the caller.
|
|
2780
|
+
function pluralStem(number: number): string {
|
|
2781
|
+
return weekdayStems[number] + 's';
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// A month name from a canonical month number. The name array has a leading
|
|
2785
|
+
// null hole for the 1-based index.
|
|
2786
|
+
function monthName(token: NameToken): string {
|
|
2787
|
+
return monthNames[+token] as string;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
|
|
2791
|
+
// The Portuguese language module: the Schedule renderer plus the language-owned
|
|
2792
|
+
// strings and option normalization.
|
|
2793
|
+
const pt: Language<PortugueseStyle> = {
|
|
2794
|
+
describe,
|
|
2795
|
+
fallback: 'um padrão cron irreconhecível',
|
|
2796
|
+
options: normalizeOptions,
|
|
2797
|
+
reboot: 'ao iniciar o sistema',
|
|
2798
|
+
// A description ending in a period already carries it, so closing the
|
|
2799
|
+
// sentence must not double it.
|
|
2800
|
+
sentence: (description) =>
|
|
2801
|
+
'Se executa ' + description + (description.endsWith('.') ? '' : '.')
|
|
2802
|
+
};
|
|
2803
|
+
|
|
2804
|
+
export default pt;
|