cronli5 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.md +4 -4
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +514 -407
- package/dist/cronli5.js +514 -407
- package/dist/lang/de.cjs +296 -225
- package/dist/lang/de.js +296 -225
- package/dist/lang/en.cjs +471 -364
- package/dist/lang/en.js +471 -364
- package/dist/lang/es.cjs +318 -281
- package/dist/lang/es.js +318 -281
- package/dist/lang/fi.cjs +326 -276
- package/dist/lang/fi.js +326 -276
- package/dist/lang/zh.cjs +308 -236
- package/dist/lang/zh.js +308 -236
- package/package.json +1 -1
- package/src/core/analyze.ts +22 -21
- package/src/core/cadence.ts +164 -0
- package/src/core/index.ts +3 -1
- package/src/core/normalize.ts +3 -3
- package/src/core/parse.ts +1 -1
- package/src/core/{ir.ts → schedule.ts} +23 -24
- package/src/core/shapes.ts +8 -1
- package/src/core/specs.ts +1 -1
- package/src/core/util.ts +4 -83
- package/src/core/validate.ts +2 -2
- package/src/core/weekday.ts +54 -0
- package/src/cronli5.ts +7 -7
- package/src/lang/de/index.ts +329 -288
- package/src/lang/en/dialects.ts +1 -1
- package/src/lang/en/index.ts +640 -516
- package/src/lang/es/index.ts +342 -374
- package/src/lang/es/notes.md +1 -1
- package/src/lang/fi/dialects.ts +1 -1
- package/src/lang/fi/index.ts +367 -372
- package/src/lang/fi/notes.md +23 -8
- package/src/lang/fi/status.json +1 -1
- package/src/lang/zh/index.ts +344 -262
- package/src/types.ts +6 -6
- package/types/core/analyze.d.ts +4 -4
- package/types/core/cadence.d.ts +33 -0
- package/types/core/index.d.ts +3 -1
- package/types/core/normalize.d.ts +1 -1
- package/types/core/parse.d.ts +1 -1
- package/types/core/{ir.d.ts → schedule.d.ts} +16 -21
- package/types/core/shapes.d.ts +2 -1
- package/types/core/specs.d.ts +1 -1
- package/types/core/util.d.ts +1 -15
- package/types/core/weekday.d.ts +10 -0
- package/types/lang/de/index.d.ts +1 -1
- package/types/lang/en/dialects.d.ts +1 -1
- package/types/lang/en/index.d.ts +1 -1
- package/types/lang/es/index.d.ts +1 -1
- package/types/lang/fi/dialects.d.ts +1 -1
- package/types/lang/fi/index.d.ts +1 -1
- package/types/lang/zh/index.d.ts +1 -1
- package/types/types.d.ts +5 -5
package/src/lang/fi/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The Finnish language module: renders an analyzed cron pattern (the
|
|
1
|
+
// The Finnish language module: renders an analyzed cron pattern (the Schedule
|
|
2
2
|
// produced by core `analyze`) as natural Finnish. Anchored to
|
|
3
3
|
// Kielitoimiston ohjepankki and SFS 4175; see notes.md.
|
|
4
4
|
//
|
|
@@ -11,14 +11,18 @@
|
|
|
11
11
|
|
|
12
12
|
import {clockDigits, numeral} from '../../core/format.js';
|
|
13
13
|
import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
|
|
14
|
+
import {isOpenStep} from '../../core/shapes.js';
|
|
14
15
|
import {
|
|
15
|
-
arithmeticStep,
|
|
16
|
-
|
|
16
|
+
arithmeticStep, hourListStride, offsetCleanStride,
|
|
17
|
+
renderStride as chooseStride, segmentsOf, singleValues, stepSegment
|
|
18
|
+
} from '../../core/cadence.js';
|
|
19
|
+
import {orderWeekdaysForDisplay} from '../../core/weekday.js';
|
|
20
|
+
import {toFieldNumber} from '../../core/util.js';
|
|
17
21
|
import {resolveDialect} from './dialects.js';
|
|
18
22
|
import type {
|
|
19
|
-
ClockTime, HourTimesPlan,
|
|
23
|
+
ClockTime, HourTimesPlan, Schedule, Language, NormalizedOptions, PlanNode,
|
|
20
24
|
Segment
|
|
21
|
-
} from '../../core/
|
|
25
|
+
} from '../../core/schedule.js';
|
|
22
26
|
import type {Cronli5Options} from '../../types.js';
|
|
23
27
|
|
|
24
28
|
// A step segment, the only Segment variant carrying `startToken`,
|
|
@@ -39,12 +43,6 @@ interface HourWindow {
|
|
|
39
43
|
last: number;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
// The first segment of a step field, narrowed to its step variant. Step
|
|
43
|
-
// shapes always classify their (single) segment as a step.
|
|
44
|
-
function stepSegment(segments: Segment[]): StepSegment {
|
|
45
|
-
return segments[0] as StepSegment;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
46
|
// A `{hour, minute, second?}` time end for the digit/range helpers.
|
|
49
47
|
interface TimeParts {
|
|
50
48
|
hour: number;
|
|
@@ -211,94 +209,134 @@ function normalizeOptions(options?: Cronli5Options): NormalizedOptions {
|
|
|
211
209
|
|
|
212
210
|
// A restricted-month date-or-weekday union: both the date and weekday are
|
|
213
211
|
// restricted AND the month is restricted. When true, the month leads so it
|
|
214
|
-
// scopes both arms, and the
|
|
215
|
-
function restrictedMonthUnion(
|
|
216
|
-
return
|
|
217
|
-
|
|
212
|
+
// scopes both arms, and the inclusive "tai" union comes last.
|
|
213
|
+
function restrictedMonthUnion(schedule: Schedule): boolean {
|
|
214
|
+
return schedule.pattern.date !== '*' && schedule.pattern.weekday !== '*' &&
|
|
215
|
+
schedule.pattern.month !== '*';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// An open interval-2 day-of-month step covers the odd days (1, 3, 5, …, 31),
|
|
219
|
+
// resetting each month — so in a union arm it reads as the parity class
|
|
220
|
+
// "kuukauden parittomina päivinä" (the essive plural of "odd days of the
|
|
221
|
+
// month"), never the continuous "joka toinen päivä" (which implies an
|
|
222
|
+
// unbroken 48-hour cycle across month boundaries) nor a 16-date enumeration
|
|
223
|
+
// that would bury the union beside the "tai". `*/2` and `1/2` are the odd
|
|
224
|
+
// days. Mirrors en's odd-numbered-day idiom; null when not such a step.
|
|
225
|
+
function oddDayUnion(dateField: string): string | null {
|
|
226
|
+
if (!isOpenStep(dateField)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const [start, step] = dateField.split('/');
|
|
231
|
+
|
|
232
|
+
return (start === '*' || start === '1') && +step === 2 ?
|
|
233
|
+
'kuukauden parittomina päivinä' :
|
|
234
|
+
null;
|
|
218
235
|
}
|
|
219
236
|
|
|
220
|
-
// The DOM arm of a restricted-month
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
function unionDateArm(
|
|
224
|
-
return quartzDatePhrase(
|
|
225
|
-
|
|
237
|
+
// The DOM arm of a restricted-month union. Under a fronted month an ordinary
|
|
238
|
+
// date drops the generic "kuukauden" anchor; a Quartz date keeps its idiom
|
|
239
|
+
// unchanged; an open `*/2` step reads as the odd-day parity class.
|
|
240
|
+
function unionDateArm(schedule: Schedule): string {
|
|
241
|
+
return quartzDatePhrase(schedule.pattern.date) ||
|
|
242
|
+
oddDayUnion(schedule.pattern.date) ||
|
|
243
|
+
dateWords(schedule) + ' päivänä';
|
|
226
244
|
}
|
|
227
245
|
|
|
228
|
-
//
|
|
229
|
-
|
|
246
|
+
// The weekday arm of a union. A Monday-through-Friday range reads as the
|
|
247
|
+
// recurring weekday class "arkisin" (= weekdays), parallel to the recurring
|
|
248
|
+
// date arm beside it; everything else defers to the general weekday qualifier.
|
|
249
|
+
function unionWeekdayArm(schedule: Schedule): string {
|
|
250
|
+
const segments = segmentsOf(schedule, 'weekday');
|
|
251
|
+
|
|
252
|
+
if (segments.length === 1 && segments[0].kind === 'range' &&
|
|
253
|
+
segments[0].bounds[0] === '1' && segments[0].bounds[1] === '5') {
|
|
254
|
+
return 'arkisin';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return weekdayQualifier(schedule);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Render an analyzed cron pattern (the Schedule) as Finnish.
|
|
261
|
+
function describe(schedule: Schedule, opts: NormalizedOptions): string {
|
|
230
262
|
// A restricted-month date-or-weekday union: the month leads so it scopes
|
|
231
|
-
// both arms, then the
|
|
232
|
-
|
|
233
|
-
|
|
263
|
+
// both arms, then the inclusive "tai" union comes last. Finnish "joko … tai"
|
|
264
|
+
// is the EXCLUSIVE disjunction (only one of the two), so it cannot express
|
|
265
|
+
// cron's union — plain "tai" reads inclusively.
|
|
266
|
+
if (restrictedMonthUnion(schedule)) {
|
|
267
|
+
const timePart = render(schedule, schedule.plan, opts);
|
|
234
268
|
|
|
235
269
|
return applyYear(
|
|
236
|
-
monthPhrase(
|
|
237
|
-
'
|
|
238
|
-
|
|
270
|
+
monthPhrase(schedule) + ' ' + timePart +
|
|
271
|
+
' ' + unionDateArm(schedule) + ' tai ' +
|
|
272
|
+
unionWeekdayArm(schedule),
|
|
273
|
+
schedule,
|
|
239
274
|
opts
|
|
240
275
|
);
|
|
241
276
|
}
|
|
242
277
|
|
|
243
|
-
return applyYear(render(
|
|
278
|
+
return applyYear(render(schedule, schedule.plan, opts), schedule, opts);
|
|
244
279
|
}
|
|
245
280
|
|
|
246
281
|
// Render one plan node. `composeSeconds` recurses with its `rest` plan.
|
|
247
|
-
function render(
|
|
282
|
+
function render(
|
|
283
|
+
schedule: Schedule, plan: PlanNode, opts: NormalizedOptions
|
|
284
|
+
): string {
|
|
248
285
|
// The renderers map each handles one `kind`; the dispatch indexes the
|
|
249
286
|
// union, which TypeScript cannot narrow per-key, so the lookup is cast
|
|
250
287
|
// to a renderer accepting this node's plan.
|
|
251
288
|
const renderer = renderers[plan.kind] as
|
|
252
|
-
(
|
|
289
|
+
(schedule: Schedule, plan: PlanNode, opts: NormalizedOptions) => string;
|
|
253
290
|
|
|
254
|
-
return renderer(
|
|
291
|
+
return renderer(schedule, plan, opts);
|
|
255
292
|
}
|
|
256
293
|
|
|
257
294
|
// --- Seconds renderers. ---
|
|
258
295
|
|
|
259
296
|
function renderEverySecond(
|
|
260
|
-
|
|
297
|
+
schedule: Schedule,
|
|
261
298
|
plan: Extract<PlanNode, {kind: 'everySecond'}>,
|
|
262
299
|
opts: NormalizedOptions
|
|
263
300
|
): string {
|
|
264
|
-
return 'joka sekunti' + trailingQualifier(
|
|
301
|
+
return 'joka sekunti' + trailingQualifier(schedule, opts);
|
|
265
302
|
}
|
|
266
303
|
|
|
267
304
|
function renderStandaloneSeconds(
|
|
268
|
-
|
|
305
|
+
schedule: Schedule,
|
|
269
306
|
plan: Extract<PlanNode, {kind: 'standaloneSeconds'}>,
|
|
270
307
|
opts: NormalizedOptions
|
|
271
308
|
): string {
|
|
272
|
-
return secondsLeadClause(
|
|
309
|
+
return secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
|
|
273
310
|
}
|
|
274
311
|
|
|
275
312
|
function renderSecondPastMinute(
|
|
276
|
-
|
|
313
|
+
schedule: Schedule,
|
|
277
314
|
plan: Extract<PlanNode, {kind: 'secondPastMinute'}>,
|
|
278
315
|
opts: NormalizedOptions
|
|
279
316
|
): string {
|
|
280
|
-
return atMarks(
|
|
281
|
-
trailingQualifier(
|
|
317
|
+
return atMarks(schedule.pattern.second, units.second, true) +
|
|
318
|
+
trailingQualifier(schedule, opts);
|
|
282
319
|
}
|
|
283
320
|
|
|
284
321
|
// A meaningful second combined with a single specific minute (and an
|
|
285
322
|
// open hour): a single second folds into one shared "kohdalla"; a list,
|
|
286
323
|
// range, or step leads with its own clause.
|
|
287
324
|
function renderSecondsWithinMinute(
|
|
288
|
-
|
|
325
|
+
schedule: Schedule,
|
|
289
326
|
plan: Extract<PlanNode, {kind: 'secondsWithinMinute'}>,
|
|
290
327
|
opts: NormalizedOptions
|
|
291
328
|
): string {
|
|
292
|
-
const minuteField =
|
|
329
|
+
const minuteField = schedule.pattern.minute;
|
|
293
330
|
|
|
294
331
|
if (plan.singleSecond) {
|
|
295
332
|
return units.minute.mark + ' ' + minuteField + ' ' +
|
|
296
|
-
units.minute.gen + ' ja ' +
|
|
297
|
-
units.second.gen + ' kohdalla' + trailingQualifier(
|
|
333
|
+
units.minute.gen + ' ja ' + schedule.pattern.second + ' ' +
|
|
334
|
+
units.second.gen + ' kohdalla' + trailingQualifier(schedule, opts);
|
|
298
335
|
}
|
|
299
336
|
|
|
300
|
-
return secondsLeadClause(
|
|
301
|
-
atMarks(minuteField, units.minute, true) +
|
|
337
|
+
return secondsLeadClause(schedule, opts) + ', ' +
|
|
338
|
+
atMarks(minuteField, units.minute, true) +
|
|
339
|
+
trailingQualifier(schedule, opts);
|
|
302
340
|
}
|
|
303
341
|
|
|
304
342
|
// A meaningful second composed over a minute-step cadence: the step leads and
|
|
@@ -309,38 +347,38 @@ function renderSecondsWithinMinute(
|
|
|
309
347
|
// renderMinuteFrequency logic; its hours-first reorder is intentionally NOT
|
|
310
348
|
// applied (the step-leads form is the correct shape for this construction).
|
|
311
349
|
function composeSecondsOverMinuteStep(
|
|
312
|
-
|
|
350
|
+
schedule: Schedule,
|
|
313
351
|
freq: Extract<PlanNode, {kind: 'minuteFrequency'}>,
|
|
314
352
|
opts: NormalizedOptions
|
|
315
353
|
): string {
|
|
316
|
-
const seg = stepSegment(
|
|
354
|
+
const seg = stepSegment(schedule, 'minute');
|
|
317
355
|
const stepPhrase = stepCycle60(seg, units.minute, opts);
|
|
318
356
|
|
|
319
357
|
if (freq.hours.kind === 'during' && minuteStepIsAnchored(seg)) {
|
|
320
358
|
// The step renders as an anchored kohdalla list rather than a cadence, so
|
|
321
359
|
// the hours-first reorder applies here too: bare hours lead, minute anchors
|
|
322
360
|
// follow, then the seconds clause.
|
|
323
|
-
const bareHours = kloFromTimes(
|
|
361
|
+
const bareHours = kloFromTimes(schedule, freq.hours.times, opts);
|
|
324
362
|
|
|
325
|
-
return hoursFirstMinutes(bareHours,
|
|
326
|
-
secondsLeadClause(
|
|
363
|
+
return hoursFirstMinutes(bareHours, schedule, opts) + ', ' +
|
|
364
|
+
secondsLeadClause(schedule, opts) + trailingQualifier(schedule, opts);
|
|
327
365
|
}
|
|
328
366
|
|
|
329
367
|
let hourClause = '';
|
|
330
368
|
|
|
331
369
|
if (freq.hours.kind === 'during') {
|
|
332
|
-
hourClause = ' ' + hourWindowsFromTimes(
|
|
370
|
+
hourClause = ' ' + hourWindowsFromTimes(schedule, freq.hours.times, opts);
|
|
333
371
|
}
|
|
334
372
|
else if (freq.hours.kind === 'window') {
|
|
335
373
|
hourClause = ' ' + hourWindow(freq.hours, opts);
|
|
336
374
|
}
|
|
337
375
|
else if (freq.hours.kind === 'step') {
|
|
338
376
|
hourClause = ' ' +
|
|
339
|
-
everyNthHour(stepSegment(
|
|
377
|
+
everyNthHour(stepSegment(schedule, 'hour'), opts);
|
|
340
378
|
}
|
|
341
379
|
|
|
342
|
-
return stepPhrase + ', ' + secondsLeadClause(
|
|
343
|
-
hourClause + trailingQualifier(
|
|
380
|
+
return stepPhrase + ', ' + secondsLeadClause(schedule, opts) +
|
|
381
|
+
hourClause + trailingQualifier(schedule, opts);
|
|
344
382
|
}
|
|
345
383
|
|
|
346
384
|
// The hour-cadence rendering of a compose-seconds plan whose clock-time rest
|
|
@@ -348,24 +386,25 @@ function composeSecondsOverMinuteStep(
|
|
|
348
386
|
// when that does not apply (a non-clock rest, a multi-valued minute, or an
|
|
349
387
|
// hour that is not a stride).
|
|
350
388
|
function composeHourCadence(
|
|
351
|
-
|
|
389
|
+
schedule: Schedule,
|
|
352
390
|
plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
|
|
353
391
|
opts: NormalizedOptions
|
|
354
392
|
): string | null {
|
|
355
393
|
const clockRest = plan.rest.kind === 'clockTimes' ||
|
|
356
394
|
plan.rest.kind === 'compactClockTimes';
|
|
357
395
|
|
|
358
|
-
if (!clockRest ||
|
|
396
|
+
if (!clockRest || schedule.shapes.minute !== 'single') {
|
|
359
397
|
return null;
|
|
360
398
|
}
|
|
361
399
|
|
|
362
|
-
const minute = +
|
|
400
|
+
const minute = +schedule.pattern.minute;
|
|
363
401
|
|
|
364
|
-
return hourCadence(
|
|
402
|
+
return hourCadence(schedule, minute, opts) ??
|
|
403
|
+
hourRangeCadence(schedule, minute, opts);
|
|
365
404
|
}
|
|
366
405
|
|
|
367
406
|
function renderComposeSeconds(
|
|
368
|
-
|
|
407
|
+
schedule: Schedule,
|
|
369
408
|
plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
|
|
370
409
|
opts: NormalizedOptions
|
|
371
410
|
): string {
|
|
@@ -373,7 +412,7 @@ function renderComposeSeconds(
|
|
|
373
412
|
// minute is a cadence, not a wall of clock times: the second/minute lead,
|
|
374
413
|
// then the hour cadence ("30 sekunnin kohdalla, kahden tunnin välein"). The
|
|
375
414
|
// clock-time rest would otherwise cross-multiply the hours.
|
|
376
|
-
const cadence = composeHourCadence(
|
|
415
|
+
const cadence = composeHourCadence(schedule, plan, opts);
|
|
377
416
|
|
|
378
417
|
if (cadence !== null) {
|
|
379
418
|
return cadence;
|
|
@@ -382,8 +421,8 @@ function renderComposeSeconds(
|
|
|
382
421
|
// When the rest is a minute-step cadence, the step leads and the second
|
|
383
422
|
// anchor follows after a comma (the comma marks the granularity boundary
|
|
384
423
|
// between the two levels, not a flat list).
|
|
385
|
-
if (plan.rest.kind === 'minuteFrequency' &&
|
|
386
|
-
return composeSecondsOverMinuteStep(
|
|
424
|
+
if (plan.rest.kind === 'minuteFrequency' && schedule.pattern.second !== '*') {
|
|
425
|
+
return composeSecondsOverMinuteStep(schedule, plan.rest, opts);
|
|
387
426
|
}
|
|
388
427
|
|
|
389
428
|
// A sub-minute second with the minute pinned to 0 and a specific hour: the
|
|
@@ -394,7 +433,7 @@ function renderComposeSeconds(
|
|
|
394
433
|
// ("joka sekunti minuutin 9.00 aikana, joka päivä").
|
|
395
434
|
if (plan.rest.kind === 'clockTimes' &&
|
|
396
435
|
plan.rest.times.every((time) => +time.minute === 0)) {
|
|
397
|
-
return composeMinuteZero(
|
|
436
|
+
return composeMinuteZero(schedule, plan.rest, opts);
|
|
398
437
|
}
|
|
399
438
|
|
|
400
439
|
// A wildcard second under a minute */2 with a wildcard hour juxtaposes two
|
|
@@ -402,8 +441,8 @@ function renderComposeSeconds(
|
|
|
402
441
|
// välein"). Bind them as "every second of every other minute" ("joka sekunti
|
|
403
442
|
// joka toisena minuuttina"), mirroring English. Other strides, a restricted
|
|
404
443
|
// hour, and an hour cadence keep the juxtaposed form.
|
|
405
|
-
if (isEveryOtherMinuteSeconds(
|
|
406
|
-
return secondsLeadClause(
|
|
444
|
+
if (isEveryOtherMinuteSeconds(schedule, plan)) {
|
|
445
|
+
return secondsLeadClause(schedule, opts) + ' joka toisena minuuttina';
|
|
407
446
|
}
|
|
408
447
|
|
|
409
448
|
// A compact clock-time rest folds a meaningful SINGLE second into its own
|
|
@@ -411,24 +450,24 @@ function renderComposeSeconds(
|
|
|
411
450
|
// double it. A wildcard or stepped second is not folded there (no
|
|
412
451
|
// clockSecond), so it still leads its own clause here.
|
|
413
452
|
const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
|
|
414
|
-
|
|
415
|
-
const lead = restOwnsLead ? '' : secondsLeadClause(
|
|
453
|
+
schedule.analyses.clockSecond;
|
|
454
|
+
const lead = restOwnsLead ? '' : secondsLeadClause(schedule, opts) + ', ';
|
|
416
455
|
|
|
417
|
-
return lead + render(
|
|
456
|
+
return lead + render(schedule, plan.rest, opts);
|
|
418
457
|
}
|
|
419
458
|
|
|
420
459
|
// A wildcard second over an unoffset minute */2 with a wildcard hour: the two
|
|
421
460
|
// cadences read as contradictory side by side, so they bind into one.
|
|
422
461
|
function isEveryOtherMinuteSeconds(
|
|
423
|
-
|
|
462
|
+
schedule: Schedule,
|
|
424
463
|
plan: Extract<PlanNode, {kind: 'composeSeconds'}>
|
|
425
464
|
): boolean {
|
|
426
|
-
if (plan.rest.kind !== 'minuteFrequency' ||
|
|
427
|
-
|
|
465
|
+
if (plan.rest.kind !== 'minuteFrequency' || schedule.pattern.second !== '*' ||
|
|
466
|
+
schedule.shapes.hour !== 'wildcard') {
|
|
428
467
|
return false;
|
|
429
468
|
}
|
|
430
469
|
|
|
431
|
-
const seg = stepSegment(
|
|
470
|
+
const seg = stepSegment(schedule, 'minute');
|
|
432
471
|
|
|
433
472
|
return seg.startToken === '*' && seg.interval === 2;
|
|
434
473
|
}
|
|
@@ -438,7 +477,7 @@ function isEveryOtherMinuteSeconds(
|
|
|
438
477
|
// a range — a range would round-trip back to the whole hour) and trail the day
|
|
439
478
|
// qualifier ("joka sekunti minuutin 9.00 aikana, joka päivä").
|
|
440
479
|
function composeMinuteZero(
|
|
441
|
-
|
|
480
|
+
schedule: Schedule,
|
|
442
481
|
rest: Extract<PlanNode, {kind: 'clockTimes'}>,
|
|
443
482
|
opts: NormalizedOptions
|
|
444
483
|
): string {
|
|
@@ -449,16 +488,18 @@ function composeMinuteZero(
|
|
|
449
488
|
const frame = clocks.length === 1 ?
|
|
450
489
|
'minuutin ' + clocks[0] :
|
|
451
490
|
'minuuttien ' + joinList(clocks);
|
|
452
|
-
const dayTrail = leadingQualifier(
|
|
491
|
+
const dayTrail = leadingQualifier(schedule, opts).trimEnd();
|
|
453
492
|
|
|
454
|
-
return secondsLeadClause(
|
|
493
|
+
return secondsLeadClause(schedule, opts) + ' ' + frame + ' aikana' +
|
|
455
494
|
(dayTrail ? ', ' + dayTrail : '');
|
|
456
495
|
}
|
|
457
496
|
|
|
458
497
|
// The leading clause describing a second field relative to the minute.
|
|
459
|
-
function secondsLeadClause(
|
|
460
|
-
|
|
461
|
-
|
|
498
|
+
function secondsLeadClause(
|
|
499
|
+
schedule: Schedule, opts: NormalizedOptions
|
|
500
|
+
): string {
|
|
501
|
+
const secondField = schedule.pattern.second;
|
|
502
|
+
const shape = schedule.shapes.second;
|
|
462
503
|
|
|
463
504
|
if (secondField === '*') {
|
|
464
505
|
return 'joka sekunti';
|
|
@@ -466,13 +507,13 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
|
|
|
466
507
|
|
|
467
508
|
if (shape === 'step') {
|
|
468
509
|
// A step shape always has segments whose first is a step segment.
|
|
469
|
-
return stepCycle60(stepSegment(
|
|
510
|
+
return stepCycle60(stepSegment(schedule, 'second'),
|
|
470
511
|
units.second, opts);
|
|
471
512
|
}
|
|
472
513
|
|
|
473
514
|
// The "joka minuutti" frequency mark is true only when the minute is open;
|
|
474
515
|
// with a fixed minute the second fires within those minutes, not every one.
|
|
475
|
-
const marked =
|
|
516
|
+
const marked = schedule.pattern.minute === '*';
|
|
476
517
|
|
|
477
518
|
if (shape === 'single') {
|
|
478
519
|
return atMarks(secondField, units.second, marked);
|
|
@@ -480,53 +521,57 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
|
|
|
480
521
|
|
|
481
522
|
// An offset/uneven step the core enumerated to this list reads as a stride
|
|
482
523
|
// cadence when the fires form a long-enough progression.
|
|
483
|
-
return strideFromSegments(
|
|
484
|
-
|
|
524
|
+
return strideFromSegments(
|
|
525
|
+
segmentsOf(schedule, 'second'), units.second, opts
|
|
526
|
+
) ??
|
|
527
|
+
atMarks(joinList(segmentWords(segmentsOf(schedule, 'second'))),
|
|
485
528
|
units.second, marked);
|
|
486
529
|
}
|
|
487
530
|
|
|
488
531
|
// --- Minute renderers. ---
|
|
489
532
|
|
|
490
533
|
function renderEveryMinute(
|
|
491
|
-
|
|
534
|
+
schedule: Schedule,
|
|
492
535
|
plan: Extract<PlanNode, {kind: 'everyMinute'}>,
|
|
493
536
|
opts: NormalizedOptions
|
|
494
537
|
): string {
|
|
495
|
-
return 'joka minuutti' + trailingQualifier(
|
|
538
|
+
return 'joka minuutti' + trailingQualifier(schedule, opts);
|
|
496
539
|
}
|
|
497
540
|
|
|
498
541
|
function renderSingleMinute(
|
|
499
|
-
|
|
542
|
+
schedule: Schedule,
|
|
500
543
|
plan: Extract<PlanNode, {kind: 'singleMinute'}>,
|
|
501
544
|
opts: NormalizedOptions
|
|
502
545
|
): string {
|
|
503
|
-
return atMarks(
|
|
504
|
-
trailingQualifier(
|
|
546
|
+
return atMarks(schedule.pattern.minute, units.minute, true) +
|
|
547
|
+
trailingQualifier(schedule, opts);
|
|
505
548
|
}
|
|
506
549
|
|
|
507
550
|
function renderRangeOfMinutes(
|
|
508
|
-
|
|
551
|
+
schedule: Schedule,
|
|
509
552
|
plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
|
|
510
553
|
opts: NormalizedOptions
|
|
511
554
|
): string {
|
|
512
|
-
return minutesList(
|
|
555
|
+
return minutesList(schedule, opts) + trailingQualifier(schedule, opts);
|
|
513
556
|
}
|
|
514
557
|
|
|
515
558
|
function renderMultipleMinutes(
|
|
516
|
-
|
|
559
|
+
schedule: Schedule,
|
|
517
560
|
plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
|
|
518
561
|
opts: NormalizedOptions
|
|
519
562
|
): string {
|
|
520
|
-
return minutesList(
|
|
563
|
+
return minutesList(schedule, opts) + trailingQualifier(schedule, opts);
|
|
521
564
|
}
|
|
522
565
|
|
|
523
566
|
// "joka tunti 0, 15 ja 30 minuutin kohdalla" (or a dash range). An offset/
|
|
524
567
|
// uneven step the core enumerated to this list reads as a stride cadence when
|
|
525
568
|
// the fires form a long-enough progression ("kahden minuutin välein
|
|
526
569
|
// minuutista 3 minuuttiin 59").
|
|
527
|
-
function minutesList(
|
|
528
|
-
return strideFromSegments(
|
|
529
|
-
|
|
570
|
+
function minutesList(schedule: Schedule, opts: NormalizedOptions): string {
|
|
571
|
+
return strideFromSegments(
|
|
572
|
+
segmentsOf(schedule, 'minute'), units.minute, opts
|
|
573
|
+
) ??
|
|
574
|
+
atMarks(joinList(segmentWords(segmentsOf(schedule, 'minute'))),
|
|
530
575
|
units.minute, true);
|
|
531
576
|
}
|
|
532
577
|
|
|
@@ -534,9 +579,11 @@ function minutesList(ir: IR, opts: NormalizedOptions): string {
|
|
|
534
579
|
// the "joka tunti" frequency would be redundant: "0–30 minuutin
|
|
535
580
|
// kohdalla". A progression reads as its bounded cadence (which carries no
|
|
536
581
|
// per-hour frequency to drop).
|
|
537
|
-
function bareMinutes(
|
|
538
|
-
return strideFromSegments(
|
|
539
|
-
|
|
582
|
+
function bareMinutes(schedule: Schedule, opts: NormalizedOptions): string {
|
|
583
|
+
return strideFromSegments(
|
|
584
|
+
segmentsOf(schedule, 'minute'), units.minute, opts
|
|
585
|
+
) ??
|
|
586
|
+
atMarks(joinList(segmentWords(segmentsOf(schedule, 'minute'))),
|
|
540
587
|
units.minute, false);
|
|
541
588
|
}
|
|
542
589
|
|
|
@@ -589,35 +636,35 @@ function hoursAreRangeIsolated(segments: Segment[]): boolean {
|
|
|
589
636
|
// hours-first.
|
|
590
637
|
function hoursFirstMinutes(
|
|
591
638
|
hoursStr: string,
|
|
592
|
-
|
|
639
|
+
schedule: Schedule,
|
|
593
640
|
opts: NormalizedOptions
|
|
594
641
|
): string {
|
|
595
642
|
// An offset/uneven step the core enumerated to this list reads as a stride
|
|
596
643
|
// cadence ("aina kahden minuutin välein minuutista 3 minuuttiin 59") when
|
|
597
644
|
// the fires form a long-enough progression, rather than the kohdalla list.
|
|
598
645
|
const stride =
|
|
599
|
-
strideFromSegments(
|
|
646
|
+
strideFromSegments(segmentsOf(schedule, 'minute'), units.minute, opts);
|
|
600
647
|
|
|
601
648
|
if (stride) {
|
|
602
649
|
return hoursStr + ' aina ' + stride;
|
|
603
650
|
}
|
|
604
651
|
|
|
605
652
|
return hoursStr + ' aina minuuttien ' +
|
|
606
|
-
joinList(segmentWords(
|
|
653
|
+
joinList(segmentWords(segmentsOf(schedule, 'minute'))) + ' kohdalla';
|
|
607
654
|
}
|
|
608
655
|
|
|
609
656
|
// Hour segment times for a range+isolated pattern: joins the isolated hour
|
|
610
657
|
// with "sekä klo" rather than "ja", marking it as discrete rather than a
|
|
611
658
|
// range extension. Used in bare-hour context only.
|
|
612
659
|
function hourSegmentTimesWithSeka(
|
|
613
|
-
|
|
660
|
+
schedule: Schedule,
|
|
614
661
|
minute: number,
|
|
615
662
|
second: number | null | undefined,
|
|
616
663
|
opts: NormalizedOptions
|
|
617
664
|
): string {
|
|
618
665
|
const pieces: string[] = [];
|
|
619
666
|
|
|
620
|
-
|
|
667
|
+
segmentsOf(schedule, 'hour').forEach(function clock(segment: Segment) {
|
|
621
668
|
if (segment.kind === 'range') {
|
|
622
669
|
pieces.push(rangeDigits(
|
|
623
670
|
{hour: +segment.bounds[0], minute, second},
|
|
@@ -634,36 +681,36 @@ function hourSegmentTimesWithSeka(
|
|
|
634
681
|
|
|
635
682
|
// A repeating minute step, qualified by the active hour window(s).
|
|
636
683
|
function renderMinuteFrequency(
|
|
637
|
-
|
|
684
|
+
schedule: Schedule,
|
|
638
685
|
plan: Extract<PlanNode, {kind: 'minuteFrequency'}>,
|
|
639
686
|
opts: NormalizedOptions
|
|
640
687
|
): string {
|
|
641
|
-
const seg = stepSegment(
|
|
688
|
+
const seg = stepSegment(schedule, 'minute');
|
|
642
689
|
|
|
643
690
|
if (plan.hours.kind === 'during') {
|
|
644
691
|
// A bounded or uneven hour stride reads as its endpoint-pinning cadence
|
|
645
692
|
// after the minute step ("15 minuutin välein, viiden tunnin välein klo
|
|
646
693
|
// 0–20").
|
|
647
|
-
const cadence = unevenHourCadence(
|
|
694
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
648
695
|
|
|
649
696
|
if (cadence !== null) {
|
|
650
697
|
return stepCycle60(seg, units.minute, opts) + ', ' + cadence +
|
|
651
|
-
trailingQualifier(
|
|
698
|
+
trailingQualifier(schedule, opts);
|
|
652
699
|
}
|
|
653
700
|
|
|
654
701
|
// When the step renders as anchored ("kohdalla"), the per-hour windows
|
|
655
702
|
// are redundant — use bare clock hours instead, then reorder to
|
|
656
703
|
// hours-first: "klo <hours> aina minuuttien <spec> kohdalla".
|
|
657
704
|
if (minuteStepIsAnchored(seg)) {
|
|
658
|
-
const bareHours = kloFromTimes(
|
|
705
|
+
const bareHours = kloFromTimes(schedule, plan.hours.times, opts);
|
|
659
706
|
|
|
660
|
-
return hoursFirstMinutes(bareHours,
|
|
661
|
-
trailingQualifier(
|
|
707
|
+
return hoursFirstMinutes(bareHours, schedule, opts) +
|
|
708
|
+
trailingQualifier(schedule, opts);
|
|
662
709
|
}
|
|
663
710
|
|
|
664
711
|
return stepCycle60(seg, units.minute, opts) + ' ' +
|
|
665
|
-
hourWindowsFromTimes(
|
|
666
|
-
trailingQualifier(
|
|
712
|
+
hourWindowsFromTimes(schedule, plan.hours.times, opts) +
|
|
713
|
+
trailingQualifier(schedule, opts);
|
|
667
714
|
}
|
|
668
715
|
|
|
669
716
|
let phrase = stepCycle60(seg, units.minute, opts);
|
|
@@ -673,10 +720,10 @@ function renderMinuteFrequency(
|
|
|
673
720
|
}
|
|
674
721
|
else if (plan.hours.kind === 'step') {
|
|
675
722
|
phrase += ' ' +
|
|
676
|
-
everyNthHour(stepSegment(
|
|
723
|
+
everyNthHour(stepSegment(schedule, 'hour'), opts);
|
|
677
724
|
}
|
|
678
725
|
|
|
679
|
-
return phrase + trailingQualifier(
|
|
726
|
+
return phrase + trailingQualifier(schedule, opts);
|
|
680
727
|
}
|
|
681
728
|
|
|
682
729
|
// "joka minuutti klo 9.00–9.59". A wildcard minute is the whole hour, so it
|
|
@@ -684,74 +731,76 @@ function renderMinuteFrequency(
|
|
|
684
731
|
// synthesized "klo 9.00–9.59" range the source never stated; a plain range is
|
|
685
732
|
// a real window and keeps the dash form.
|
|
686
733
|
function renderMinuteSpanInHour(
|
|
687
|
-
|
|
734
|
+
schedule: Schedule,
|
|
688
735
|
plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
|
|
689
736
|
opts: NormalizedOptions
|
|
690
737
|
): string {
|
|
691
|
-
if (
|
|
738
|
+
if (schedule.pattern.minute === '*') {
|
|
692
739
|
return 'joka minuutti kello ' + plan.hour + ' aikana' +
|
|
693
|
-
trailingQualifier(
|
|
740
|
+
trailingQualifier(schedule, opts);
|
|
694
741
|
}
|
|
695
742
|
|
|
696
743
|
return 'joka minuutti ' +
|
|
697
744
|
kloRange({hour: plan.hour, minute: plan.span[0]},
|
|
698
745
|
{hour: plan.hour, minute: plan.span[1]}, opts) +
|
|
699
|
-
trailingQualifier(
|
|
746
|
+
trailingQualifier(schedule, opts);
|
|
700
747
|
}
|
|
701
748
|
|
|
702
749
|
// A minute window under discrete hours. Like Spanish, the wildcard form
|
|
703
|
-
// re-
|
|
750
|
+
// re-plans to per-hour windows; restricted minutes drop the
|
|
704
751
|
// "jokaisen tunnin" anchor, which the specific hours would contradict.
|
|
705
752
|
// A range or multi-point list over enumerated hours renders hours-first
|
|
706
753
|
// ("klo <hours> aina minuuttien <spec> kohdalla"); a range+isolated hour
|
|
707
754
|
// compound instead keeps minute-first and joins the isolated hour with
|
|
708
755
|
// "sekä klo" (mirrors renderCompactClockTimes).
|
|
709
756
|
function renderMinutesAcrossHours(
|
|
710
|
-
|
|
757
|
+
schedule: Schedule,
|
|
711
758
|
plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
|
|
712
759
|
opts: NormalizedOptions
|
|
713
760
|
): string {
|
|
714
761
|
// A bounded or uneven hour stride reads as its endpoint-pinning cadence after
|
|
715
762
|
// the minute clause ("joka minuutti, viiden tunnin välein klo 0–20"), not a
|
|
716
763
|
// wall of hour windows.
|
|
717
|
-
const cadence = unevenHourCadence(
|
|
764
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
718
765
|
|
|
719
766
|
if (plan.form === 'wildcard') {
|
|
720
767
|
return cadence ?
|
|
721
|
-
'joka minuutti, ' + cadence + trailingQualifier(
|
|
722
|
-
'joka minuutti ' + hourWindowsFromTimes(
|
|
723
|
-
trailingQualifier(
|
|
768
|
+
'joka minuutti, ' + cadence + trailingQualifier(schedule, opts) :
|
|
769
|
+
'joka minuutti ' + hourWindowsFromTimes(schedule, plan.times, opts) +
|
|
770
|
+
trailingQualifier(schedule, opts);
|
|
724
771
|
}
|
|
725
772
|
|
|
726
773
|
if (cadence !== null) {
|
|
727
|
-
return bareMinutes(
|
|
774
|
+
return bareMinutes(schedule, opts) + ', ' + cadence +
|
|
775
|
+
trailingQualifier(schedule, opts);
|
|
728
776
|
}
|
|
729
777
|
|
|
730
778
|
// Range+isolated hours: minute-first, bare minutes, sekä klo.
|
|
731
|
-
if (hoursAreRangeIsolated(
|
|
732
|
-
return bareMinutes(
|
|
733
|
-
hourSegmentTimesWithSeka(
|
|
734
|
-
trailingQualifier(
|
|
779
|
+
if (hoursAreRangeIsolated(segmentsOf(schedule, 'hour'))) {
|
|
780
|
+
return bareMinutes(schedule, opts) + ' ' +
|
|
781
|
+
hourSegmentTimesWithSeka(schedule, 0, null, opts) +
|
|
782
|
+
trailingQualifier(schedule, opts);
|
|
735
783
|
}
|
|
736
784
|
|
|
737
785
|
// Range or multi-value list (≥2 points) over enumerated hours →
|
|
738
786
|
// hours-first. A single anchored minute stays minute-first (clock already
|
|
739
787
|
// shows it).
|
|
740
|
-
const hoursStr = kloFromTimes(
|
|
788
|
+
const hoursStr = kloFromTimes(schedule, plan.times, opts);
|
|
741
789
|
|
|
742
|
-
return hoursFirstMinutes(hoursStr,
|
|
790
|
+
return hoursFirstMinutes(hoursStr, schedule, opts) +
|
|
791
|
+
trailingQualifier(schedule, opts);
|
|
743
792
|
}
|
|
744
793
|
|
|
745
794
|
function renderMinuteSpanAcrossHourStep(
|
|
746
|
-
|
|
795
|
+
schedule: Schedule,
|
|
747
796
|
plan: Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>,
|
|
748
797
|
opts: NormalizedOptions
|
|
749
798
|
): string {
|
|
750
799
|
// An hour-step plan's first hour segment is always a step segment.
|
|
751
|
-
const segment = stepSegment(
|
|
800
|
+
const segment = stepSegment(schedule, 'hour');
|
|
752
801
|
// A bounded or uneven hour stride reads as its endpoint-pinning cadence; an
|
|
753
802
|
// offset-clean stride keeps its confinement / per-step phrasing.
|
|
754
|
-
const cadence = unevenHourCadence(
|
|
803
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
755
804
|
|
|
756
805
|
// A wildcard span always sets the step off with a comma ("joka
|
|
757
806
|
// minuutti, joka toinen tunti"); a restricted span joins a plain step
|
|
@@ -761,17 +810,18 @@ function renderMinuteSpanAcrossHourStep(
|
|
|
761
810
|
// to every Nth hour; a restricted span is a per-hour window + plain step.
|
|
762
811
|
if (plan.form === 'wildcard') {
|
|
763
812
|
return 'joka minuutti ' + everyNthHour(segment, opts) +
|
|
764
|
-
trailingQualifier(
|
|
813
|
+
trailingQualifier(schedule, opts);
|
|
765
814
|
}
|
|
766
815
|
|
|
767
816
|
// A bounded or uneven stride reads as its bounded cadence after the bare
|
|
768
817
|
// minutes ("minuuteilla 0–30, kahden tunnin välein klo 9–17").
|
|
769
818
|
if (cadence !== null) {
|
|
770
|
-
return bareMinutes(
|
|
819
|
+
return bareMinutes(schedule, opts) + ', ' + cadence +
|
|
820
|
+
trailingQualifier(schedule, opts);
|
|
771
821
|
}
|
|
772
822
|
|
|
773
|
-
return bareMinutes(
|
|
774
|
-
trailingQualifier(
|
|
823
|
+
return bareMinutes(schedule, opts) + hourStepTail(segment, opts) +
|
|
824
|
+
trailingQualifier(schedule, opts);
|
|
775
825
|
}
|
|
776
826
|
|
|
777
827
|
// Whether an hour step reads as the plain "joka toinen tunti" form: a
|
|
@@ -827,65 +877,67 @@ function hourStepTail(segment: StepSegment, opts: NormalizedOptions): string {
|
|
|
827
877
|
// --- Hour renderers. ---
|
|
828
878
|
|
|
829
879
|
function renderEveryHour(
|
|
830
|
-
|
|
880
|
+
schedule: Schedule,
|
|
831
881
|
plan: Extract<PlanNode, {kind: 'everyHour'}>,
|
|
832
882
|
opts: NormalizedOptions
|
|
833
883
|
): string {
|
|
834
|
-
return 'joka tunti' + trailingQualifier(
|
|
884
|
+
return 'joka tunti' + trailingQualifier(schedule, opts);
|
|
835
885
|
}
|
|
836
886
|
|
|
837
887
|
function renderHourRange(
|
|
838
|
-
|
|
888
|
+
schedule: Schedule,
|
|
839
889
|
plan: Extract<PlanNode, {kind: 'hourRange'}>,
|
|
840
890
|
opts: NormalizedOptions
|
|
841
891
|
): string {
|
|
842
892
|
const window = hourWindow(boundedWindow(plan), opts);
|
|
843
893
|
|
|
844
894
|
if (plan.minuteForm === 'wildcard') {
|
|
845
|
-
return 'joka minuutti ' + window + trailingQualifier(
|
|
895
|
+
return 'joka minuutti ' + window + trailingQualifier(schedule, opts);
|
|
846
896
|
}
|
|
847
897
|
|
|
848
898
|
// A minute range over a single hour range renders hours-first
|
|
849
899
|
// ("klo 9.00–17.30 aina minuuttien 0–30 kohdalla").
|
|
850
900
|
if (plan.minuteForm === 'range') {
|
|
851
|
-
return hoursFirstMinutes(window,
|
|
901
|
+
return hoursFirstMinutes(window, schedule, opts) +
|
|
902
|
+
trailingQualifier(schedule, opts);
|
|
852
903
|
}
|
|
853
904
|
|
|
854
905
|
// On the hour the window joins directly ("joka tunti klo 9–17"); a
|
|
855
906
|
// discrete minute anchors its own clause first.
|
|
856
|
-
if (
|
|
857
|
-
return 'joka tunti ' + window + trailingQualifier(
|
|
907
|
+
if (schedule.pattern.minute === '0') {
|
|
908
|
+
return 'joka tunti ' + window + trailingQualifier(schedule, opts);
|
|
858
909
|
}
|
|
859
910
|
|
|
860
911
|
// A single minute makes both window ends exact fires ("klo 9.30–17.30").
|
|
861
|
-
if (
|
|
862
|
-
return atMarks(
|
|
863
|
-
kloRange({hour: plan.from, minute: +
|
|
912
|
+
if (schedule.shapes.minute === 'single') {
|
|
913
|
+
return atMarks(schedule.pattern.minute, units.minute, false) + ' ' +
|
|
914
|
+
kloRange({hour: plan.from, minute: +schedule.pattern.minute},
|
|
864
915
|
{hour: plan.to, minute: plan.last}, opts) +
|
|
865
|
-
trailingQualifier(
|
|
916
|
+
trailingQualifier(schedule, opts);
|
|
866
917
|
}
|
|
867
918
|
|
|
868
919
|
// A minute list (≥2 values) over a single hour range renders hours-first
|
|
869
920
|
// ("klo 9.00–17.30 aina minuuttien 0 ja 30 kohdalla").
|
|
870
|
-
return hoursFirstMinutes(window,
|
|
921
|
+
return hoursFirstMinutes(window, schedule, opts) +
|
|
922
|
+
trailingQualifier(schedule, opts);
|
|
871
923
|
}
|
|
872
924
|
|
|
873
925
|
function renderHourStep(
|
|
874
|
-
|
|
926
|
+
schedule: Schedule,
|
|
875
927
|
plan: Extract<PlanNode, {kind: 'hourStep'}>,
|
|
876
928
|
opts: NormalizedOptions
|
|
877
929
|
): string {
|
|
878
930
|
// A bounded or uneven hour step reads as its endpoint-pinning cadence
|
|
879
931
|
// ("kahden tunnin välein klo 9–17"); an offset-clean step keeps its bare or
|
|
880
932
|
// "alkaen" cadence.
|
|
881
|
-
const cadence = unevenHourCadence(
|
|
933
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
882
934
|
|
|
883
935
|
if (cadence !== null) {
|
|
884
|
-
return cadence + trailingQualifier(
|
|
936
|
+
return cadence + trailingQualifier(schedule, opts);
|
|
885
937
|
}
|
|
886
938
|
|
|
887
|
-
return stepHours(stepSegment(
|
|
888
|
-
trailingQualifier(
|
|
939
|
+
return stepHours(stepSegment(schedule, 'hour'), opts) +
|
|
940
|
+
trailingQualifier(schedule, opts);
|
|
889
941
|
}
|
|
890
942
|
|
|
891
943
|
// The hour-range plan as a window whose closing minute honors `boundMinute`:
|
|
@@ -907,17 +959,17 @@ function hourWindow(window: HourWindow, opts: NormalizedOptions): string {
|
|
|
907
959
|
|
|
908
960
|
// "joka päivä klo 9.30 ja 17.30".
|
|
909
961
|
function renderClockTimes(
|
|
910
|
-
|
|
962
|
+
schedule: Schedule,
|
|
911
963
|
plan: Extract<PlanNode, {kind: 'clockTimes'}>,
|
|
912
964
|
opts: NormalizedOptions
|
|
913
965
|
): string {
|
|
914
966
|
// An hour step or range (or arithmetic-progression hour list) under a single
|
|
915
967
|
// pinned minute reads as a cadence or window rather than a cross-product of
|
|
916
968
|
// clock times.
|
|
917
|
-
if (
|
|
918
|
-
const minute = +
|
|
919
|
-
const cadence = hourCadence(
|
|
920
|
-
hourRangeCadence(
|
|
969
|
+
if (schedule.shapes.minute === 'single') {
|
|
970
|
+
const minute = +schedule.pattern.minute;
|
|
971
|
+
const cadence = hourCadence(schedule, minute, opts) ??
|
|
972
|
+
hourRangeCadence(schedule, minute, opts);
|
|
921
973
|
|
|
922
974
|
if (cadence !== null) {
|
|
923
975
|
return cadence;
|
|
@@ -927,7 +979,7 @@ function renderClockTimes(
|
|
|
927
979
|
if (plan.times.length === 1) {
|
|
928
980
|
const time = plan.times[0];
|
|
929
981
|
|
|
930
|
-
return leadingQualifier(
|
|
982
|
+
return leadingQualifier(schedule, opts) +
|
|
931
983
|
timeWord(time.hour, time.minute, time.second, opts);
|
|
932
984
|
}
|
|
933
985
|
|
|
@@ -935,7 +987,7 @@ function renderClockTimes(
|
|
|
935
987
|
return timeDigits(time.hour, time.minute, time.second, opts);
|
|
936
988
|
});
|
|
937
989
|
|
|
938
|
-
return leadingQualifier(
|
|
990
|
+
return leadingQualifier(schedule, opts) + 'klo ' + joinList(digits);
|
|
939
991
|
}
|
|
940
992
|
|
|
941
993
|
// Compact form past the enumeration cap: a single minute folds into
|
|
@@ -943,7 +995,7 @@ function renderClockTimes(
|
|
|
943
995
|
// A minute list over enumerated (non-range+isolated) hours renders
|
|
944
996
|
// hours-first; a range+isolated hour pattern joins with "sekä klo".
|
|
945
997
|
function renderCompactClockTimes(
|
|
946
|
-
|
|
998
|
+
schedule: Schedule,
|
|
947
999
|
plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
|
|
948
1000
|
opts: NormalizedOptions
|
|
949
1001
|
): string {
|
|
@@ -951,15 +1003,15 @@ function renderCompactClockTimes(
|
|
|
951
1003
|
// minute reads as a cadence, not a wall of clock times. (Returns null for an
|
|
952
1004
|
// irregular list or a range, which keep folding below.)
|
|
953
1005
|
if (plan.fold) {
|
|
954
|
-
const cadence = hourCadence(
|
|
955
|
-
hourRangeCadence(
|
|
1006
|
+
const cadence = hourCadence(schedule, plan.minute, opts) ??
|
|
1007
|
+
hourRangeCadence(schedule, plan.minute, opts);
|
|
956
1008
|
|
|
957
1009
|
if (cadence !== null) {
|
|
958
1010
|
return cadence;
|
|
959
1011
|
}
|
|
960
1012
|
}
|
|
961
1013
|
|
|
962
|
-
const hourSegs =
|
|
1014
|
+
const hourSegs = segmentsOf(schedule, 'hour');
|
|
963
1015
|
|
|
964
1016
|
// Range+isolated hours: join the isolated hour with "sekä klo" to stop it
|
|
965
1017
|
// reading as a range extension. For the folded path (single minute folded
|
|
@@ -967,38 +1019,43 @@ function renderCompactClockTimes(
|
|
|
967
1019
|
// path use bare-minutes-first with a trailing qualifier.
|
|
968
1020
|
if (hoursAreRangeIsolated(hourSegs)) {
|
|
969
1021
|
if (plan.fold) {
|
|
970
|
-
return leadingQualifier(
|
|
971
|
-
hourSegmentTimesWithSeka(
|
|
972
|
-
|
|
1022
|
+
return leadingQualifier(schedule, opts) +
|
|
1023
|
+
hourSegmentTimesWithSeka(schedule, plan.minute,
|
|
1024
|
+
schedule.analyses.clockSecond, opts);
|
|
973
1025
|
}
|
|
974
1026
|
|
|
975
|
-
const phrase = bareMinutes(
|
|
976
|
-
hourSegmentTimesWithSeka(
|
|
977
|
-
trailingQualifier(
|
|
1027
|
+
const phrase = bareMinutes(schedule, opts) + ' ' +
|
|
1028
|
+
hourSegmentTimesWithSeka(schedule, 0, null, opts) +
|
|
1029
|
+
trailingQualifier(schedule, opts);
|
|
978
1030
|
|
|
979
|
-
return
|
|
980
|
-
secondsLeadClause(
|
|
1031
|
+
return schedule.analyses.clockSecond ?
|
|
1032
|
+
secondsLeadClause(schedule, opts) + ', ' + phrase :
|
|
981
1033
|
phrase;
|
|
982
1034
|
}
|
|
983
1035
|
|
|
984
1036
|
if (plan.fold) {
|
|
985
|
-
return leadingQualifier(
|
|
986
|
-
hourSegmentTimes(
|
|
1037
|
+
return leadingQualifier(schedule, opts) +
|
|
1038
|
+
hourSegmentTimes(
|
|
1039
|
+
schedule, plan.minute, schedule.analyses.clockSecond, opts
|
|
1040
|
+
);
|
|
987
1041
|
}
|
|
988
1042
|
|
|
989
1043
|
// A bounded or uneven hour stride reads as its endpoint-pinning cadence after
|
|
990
1044
|
// the bare minute clause ("minuuteilla 0, 25 ja 50, viiden tunnin välein klo
|
|
991
1045
|
// 0–20"), not a wall of clock-time columns.
|
|
992
|
-
const cadence = unevenHourCadence(
|
|
1046
|
+
const cadence = unevenHourCadence(schedule, opts);
|
|
993
1047
|
const phrase = cadence ?
|
|
994
|
-
bareMinutes(
|
|
1048
|
+
bareMinutes(schedule, opts) + ', ' + cadence +
|
|
1049
|
+
trailingQualifier(schedule, opts) :
|
|
995
1050
|
// A minute list over purely enumerated hours (step fires, all singles) —
|
|
996
1051
|
// hours-first, drop "joka tunti".
|
|
997
|
-
hoursFirstMinutes(
|
|
998
|
-
|
|
1052
|
+
hoursFirstMinutes(
|
|
1053
|
+
hourSegmentTimes(schedule, 0, null, opts), schedule, opts
|
|
1054
|
+
) +
|
|
1055
|
+
trailingQualifier(schedule, opts);
|
|
999
1056
|
|
|
1000
|
-
return
|
|
1001
|
-
secondsLeadClause(
|
|
1057
|
+
return schedule.analyses.clockSecond ?
|
|
1058
|
+
secondsLeadClause(schedule, opts) + ', ' + phrase :
|
|
1002
1059
|
phrase;
|
|
1003
1060
|
}
|
|
1004
1061
|
|
|
@@ -1050,25 +1107,21 @@ interface Stride {
|
|
|
1050
1107
|
function renderStride(stride: Stride, opts: NormalizedOptions): string {
|
|
1051
1108
|
const {interval, start, last, cycle, unit} = stride;
|
|
1052
1109
|
const cadence = genitive(interval, opts) + ' ' + unit.gen + ' välein';
|
|
1053
|
-
const tiles = cycle % interval === 0;
|
|
1054
1110
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
return cadence + ' ' + unit.ela + ' ' + start + ' ' + unit.ill + ' ' + last;
|
|
1111
|
+
return chooseStride({start, interval, cycle}, {
|
|
1112
|
+
bare: () => cadence,
|
|
1113
|
+
offset: () =>
|
|
1114
|
+
cadence + ' ' + unit.anchor + ' ' + unit.ela + ' ' + start + ' alkaen',
|
|
1115
|
+
bounded: () =>
|
|
1116
|
+
cadence + ' ' + unit.ela + ' ' + start + ' ' + unit.ill + ' ' + last
|
|
1117
|
+
});
|
|
1065
1118
|
}
|
|
1066
1119
|
|
|
1067
1120
|
// Speak a minute/second field's enumerated fires as a step cadence when they
|
|
1068
1121
|
// form an arithmetic progression long enough to beat the list (the core
|
|
1069
|
-
// enumerates an offset/uneven step to this fire list; the
|
|
1070
|
-
// the renderer recognizes the progression). Returns null for a
|
|
1071
|
-
// or a too-short list, leaving the caller to enumerate.
|
|
1122
|
+
// enumerates an offset/uneven step to this fire list; the Schedule is
|
|
1123
|
+
// unchanged, so the renderer recognizes the progression). Returns null for a
|
|
1124
|
+
// non-progression or a too-short list, leaving the caller to enumerate.
|
|
1072
1125
|
function strideFromSegments(
|
|
1073
1126
|
segments: Segment[],
|
|
1074
1127
|
unit: UnitForms,
|
|
@@ -1082,21 +1135,6 @@ function strideFromSegments(
|
|
|
1082
1135
|
null;
|
|
1083
1136
|
}
|
|
1084
1137
|
|
|
1085
|
-
// The sorted numeric values a field's segments cover, or null if any segment
|
|
1086
|
-
// is not a discrete single (a range or sub-step is not a plain fire list).
|
|
1087
|
-
function singleValues(segments: Segment[]): number[] | null {
|
|
1088
|
-
const values: number[] = [];
|
|
1089
|
-
|
|
1090
|
-
for (const segment of segments) {
|
|
1091
|
-
if (segment.kind !== 'single') {
|
|
1092
|
-
return null;
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
values.push(+segment.value);
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
return values;
|
|
1099
|
-
}
|
|
1100
1138
|
|
|
1101
1139
|
// "viiden minuutin välein", "joka tunti 0 ja 31 minuutin kohdalla", or
|
|
1102
1140
|
// "kolmen minuutin välein jokaisen tunnin minuutista 1 alkaen". A step shape
|
|
@@ -1167,74 +1205,25 @@ function hourStrideCadence(
|
|
|
1167
1205
|
): string {
|
|
1168
1206
|
const {start, interval, last} = stride;
|
|
1169
1207
|
const cadence = genitive(interval, opts) + ' tunnin välein';
|
|
1170
|
-
const tiles = 24 % interval === 0;
|
|
1171
|
-
|
|
1172
|
-
if (start === 0 && tiles) {
|
|
1173
|
-
return cadence;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
if (start < interval && tiles) {
|
|
1177
|
-
return cadence + ' klo ' + hourElatives[start] + ' alkaen';
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
return cadence + ' ' +
|
|
1181
|
-
kloRange({hour: start, minute: 0}, {hour: last, minute: 0}, opts);
|
|
1182
|
-
}
|
|
1183
1208
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
// a step when it is too long to be a deliberate clock-time list (9,17 is two
|
|
1191
|
-
// named times, not a cadence). Interval one is a plain range, never a step.
|
|
1192
|
-
function hourListStride(
|
|
1193
|
-
values: number[]
|
|
1194
|
-
): {start: number; interval: number; last: number} | null {
|
|
1195
|
-
if (values.length < 2) {
|
|
1196
|
-
return null;
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
const interval = values[1] - values[0];
|
|
1200
|
-
|
|
1201
|
-
if (interval < 2) {
|
|
1202
|
-
return null;
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
for (let i = 2; i < values.length; i += 1) {
|
|
1206
|
-
if (values[i] - values[i - 1] !== interval) {
|
|
1207
|
-
return null;
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
if (values[0] !== 0 && values.length < 5) {
|
|
1212
|
-
return null;
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
return {interval, last: values[values.length - 1], start: values[0]};
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
// Whether an hour stride wraps the day cleanly from within its first interval
|
|
1219
|
-
// (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
|
|
1220
|
-
// stride has no distinct endpoint and keeps its bare or "alkaen" cadence. Every
|
|
1221
|
-
// other stride — a uneven interval, or one starting at or past its interval (a
|
|
1222
|
-
// bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
|
|
1223
|
-
function offsetCleanStride(
|
|
1224
|
-
stride: {start: number; interval: number}
|
|
1225
|
-
): boolean {
|
|
1226
|
-
return stride.start < stride.interval && 24 % stride.interval === 0;
|
|
1209
|
+
return chooseStride({start, interval, cycle: 24}, {
|
|
1210
|
+
bare: () => cadence,
|
|
1211
|
+
offset: () => cadence + ' klo ' + hourElatives[start] + ' alkaen',
|
|
1212
|
+
bounded: () => cadence + ' ' +
|
|
1213
|
+
kloRange({hour: start, minute: 0}, {hour: last, minute: 0}, opts)
|
|
1214
|
+
});
|
|
1227
1215
|
}
|
|
1228
1216
|
|
|
1229
1217
|
// The hour field's stride, or null when the hour is not a cadence: a step
|
|
1230
1218
|
// segment yields its {start, interval, last} directly; an all-single hour list
|
|
1231
1219
|
// yields one only when its values form a step progression (so an irregular list
|
|
1232
|
-
// like 9,17 keeps enumerating). The
|
|
1233
|
-
// the stride and speaks it as a cadence, not the clock-time
|
|
1220
|
+
// like 9,17 keeps enumerating). The Schedule is unchanged — the renderer
|
|
1221
|
+
// recognizes the stride and speaks it as a cadence, not the clock-time
|
|
1222
|
+
// cross-product.
|
|
1234
1223
|
function hourStride(
|
|
1235
|
-
|
|
1224
|
+
schedule: Schedule
|
|
1236
1225
|
): {start: number; interval: number; last: number} | null {
|
|
1237
|
-
const segments =
|
|
1226
|
+
const segments = schedule.analyses.segments.hour;
|
|
1238
1227
|
|
|
1239
1228
|
// A wildcard hour carries no segments (no discrete hours to stride over).
|
|
1240
1229
|
if (!segments) {
|
|
@@ -1270,8 +1259,10 @@ function hourStride(
|
|
|
1270
1259
|
// ("…, viiden tunnin välein klo 0–20") than as a wall of clock times. An
|
|
1271
1260
|
// offset-clean stride keeps its existing confinement form, so only the
|
|
1272
1261
|
// endpoint-bearing case routes here.
|
|
1273
|
-
function unevenHourCadence(
|
|
1274
|
-
|
|
1262
|
+
function unevenHourCadence(
|
|
1263
|
+
schedule: Schedule, opts: NormalizedOptions
|
|
1264
|
+
): string | null {
|
|
1265
|
+
const stride = hourStride(schedule);
|
|
1275
1266
|
|
|
1276
1267
|
if (!stride || offsetCleanStride(stride)) {
|
|
1277
1268
|
return null;
|
|
@@ -1283,8 +1274,8 @@ function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
|
|
|
1283
1274
|
// The second's status against a pinned minute: a wildcard or sub-minute step
|
|
1284
1275
|
// fills the minute (a "minuutin ajan" frame at minute 0); a single 0 is just
|
|
1285
1276
|
// the top of the minute (no clause); anything else needs its own clause.
|
|
1286
|
-
function subMinuteSecond(
|
|
1287
|
-
return
|
|
1277
|
+
function subMinuteSecond(schedule: Schedule): boolean {
|
|
1278
|
+
return schedule.pattern.second === '*' || schedule.shapes.second === 'step';
|
|
1288
1279
|
}
|
|
1289
1280
|
|
|
1290
1281
|
// The lead clause for an hour-cadence rendering: the second and the pinned
|
|
@@ -1294,25 +1285,25 @@ function subMinuteSecond(ir: IR): boolean {
|
|
|
1294
1285
|
// ajan" frame (the whole minute-0 window). A non-zero minute is a real clock
|
|
1295
1286
|
// minute: the second leads with its own clause (if any), then the minute reads
|
|
1296
1287
|
// at its bare "kohdalla" mark.
|
|
1297
|
-
function hourCadenceLead(
|
|
1288
|
+
function hourCadenceLead(schedule: Schedule, minute: number,
|
|
1298
1289
|
opts: NormalizedOptions): string {
|
|
1299
1290
|
if (minute === 0) {
|
|
1300
|
-
if (subMinuteSecond(
|
|
1301
|
-
return secondsLeadClause(
|
|
1291
|
+
if (subMinuteSecond(schedule)) {
|
|
1292
|
+
return secondsLeadClause(schedule, opts) + ' minuutin ajan';
|
|
1302
1293
|
}
|
|
1303
1294
|
|
|
1304
|
-
return secondsLeadClause(
|
|
1295
|
+
return secondsLeadClause(schedule, opts);
|
|
1305
1296
|
}
|
|
1306
1297
|
|
|
1307
1298
|
const minutePhrase = atMarks(String(minute), units.minute, false);
|
|
1308
1299
|
|
|
1309
1300
|
// A single 0 second is just the top of the minute, so the minute leads
|
|
1310
1301
|
// alone; any other second prefixes its own clause.
|
|
1311
|
-
if (
|
|
1302
|
+
if (schedule.pattern.second === '0') {
|
|
1312
1303
|
return minutePhrase;
|
|
1313
1304
|
}
|
|
1314
1305
|
|
|
1315
|
-
return secondsLeadClause(
|
|
1306
|
+
return secondsLeadClause(schedule, opts) + ', ' + minutePhrase;
|
|
1316
1307
|
}
|
|
1317
1308
|
|
|
1318
1309
|
// Render an hour step (or arithmetic-progression hour list) under a single
|
|
@@ -1323,10 +1314,10 @@ function hourCadenceLead(ir: IR, minute: number,
|
|
|
1323
1314
|
// enumeration is no longer than the cadence: a meaningful second makes every
|
|
1324
1315
|
// clock time three digit-groups, so any stride is worth compacting; otherwise
|
|
1325
1316
|
// the stride must exceed the clock-time cap, the same point at which the core
|
|
1326
|
-
// itself stops enumerating. Renderer-only; the
|
|
1327
|
-
function hourCadence(
|
|
1317
|
+
// itself stops enumerating. Renderer-only; the Schedule is unchanged.
|
|
1318
|
+
function hourCadence(schedule: Schedule, minute: number,
|
|
1328
1319
|
opts: NormalizedOptions): string | null {
|
|
1329
|
-
const stride = hourStride(
|
|
1320
|
+
const stride = hourStride(schedule);
|
|
1330
1321
|
|
|
1331
1322
|
if (!stride) {
|
|
1332
1323
|
return null;
|
|
@@ -1339,7 +1330,7 @@ function hourCadence(ir: IR, minute: number,
|
|
|
1339
1330
|
// or "alkaen" form is no shorter than the list. A bounded or uneven stride
|
|
1340
1331
|
// has no clean wrap, so its endpoint-pinning cadence ("viiden tunnin välein
|
|
1341
1332
|
// klo 0–20") reads better however short.
|
|
1342
|
-
if (
|
|
1333
|
+
if (schedule.pattern.second === '0' && fires <= maxClockTimes &&
|
|
1343
1334
|
offsetCleanStride(stride)) {
|
|
1344
1335
|
return null;
|
|
1345
1336
|
}
|
|
@@ -1348,25 +1339,25 @@ function hourCadence(ir: IR, minute: number,
|
|
|
1348
1339
|
// stride is a confinement, not a juxtaposed cadence: it reads "minuutin ajan
|
|
1349
1340
|
// joka toisen tunnin aikana", reusing the every-Nth-hour idiom so the
|
|
1350
1341
|
// minute-0 window is never heard as the bare hour cadence.
|
|
1351
|
-
const segment =
|
|
1352
|
-
const confined = minute === 0 && subMinuteSecond(
|
|
1353
|
-
|
|
1342
|
+
const segment = segmentsOf(schedule, 'hour')[0];
|
|
1343
|
+
const confined = minute === 0 && subMinuteSecond(schedule) &&
|
|
1344
|
+
segmentsOf(schedule, 'hour').length === 1 && segment.kind === 'step' &&
|
|
1354
1345
|
cleanHourStride(segment);
|
|
1355
1346
|
|
|
1356
1347
|
if (confined) {
|
|
1357
|
-
return secondsLeadClause(
|
|
1358
|
-
everyNthHour(segment, opts) + trailingQualifier(
|
|
1348
|
+
return secondsLeadClause(schedule, opts) + ' minuutin ajan ' +
|
|
1349
|
+
everyNthHour(segment, opts) + trailingQualifier(schedule, opts);
|
|
1359
1350
|
}
|
|
1360
1351
|
|
|
1361
1352
|
// A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
|
|
1362
1353
|
// lead clause to fold in, so the bounded cadence stands on its own ("viiden
|
|
1363
1354
|
// tunnin välein klo 0–20").
|
|
1364
|
-
if (minute === 0 &&
|
|
1365
|
-
return hourStrideCadence(stride, opts) + trailingQualifier(
|
|
1355
|
+
if (minute === 0 && schedule.pattern.second === '0') {
|
|
1356
|
+
return hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
|
|
1366
1357
|
}
|
|
1367
1358
|
|
|
1368
|
-
return hourCadenceLead(
|
|
1369
|
-
hourStrideCadence(stride, opts) + trailingQualifier(
|
|
1359
|
+
return hourCadenceLead(schedule, minute, opts) + ', ' +
|
|
1360
|
+
hourStrideCadence(stride, opts) + trailingQualifier(schedule, opts);
|
|
1370
1361
|
}
|
|
1371
1362
|
|
|
1372
1363
|
// Whether an hour step is a clean stride over the whole day — unbounded,
|
|
@@ -1386,8 +1377,8 @@ function cleanHourStride(segment: StepSegment): boolean {
|
|
|
1386
1377
|
// range — and so forms a window rather than a cross-product of clock times.
|
|
1387
1378
|
// A pure single-value list (9,17) has no range to span and still enumerates;
|
|
1388
1379
|
// a step is handled by hourStride/hourCadence.
|
|
1389
|
-
function hasHourWindow(
|
|
1390
|
-
const segments =
|
|
1380
|
+
function hasHourWindow(schedule: Schedule): boolean {
|
|
1381
|
+
const segments = schedule.analyses.segments.hour;
|
|
1391
1382
|
|
|
1392
1383
|
return !!segments && segments.some(function range(segment: Segment) {
|
|
1393
1384
|
return segment.kind === 'range';
|
|
@@ -1399,10 +1390,12 @@ function hasHourWindow(ir: IR): boolean {
|
|
|
1399
1390
|
// with "sekä klo" ("klo 9–20 sekä klo 22"), the same idiom the bare folded
|
|
1400
1391
|
// window uses. The minute has folded into the lead, so the window closes on
|
|
1401
1392
|
// the top of its final hour.
|
|
1402
|
-
function hourRangeWindowTail(
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1393
|
+
function hourRangeWindowTail(
|
|
1394
|
+
schedule: Schedule, opts: NormalizedOptions
|
|
1395
|
+
): string {
|
|
1396
|
+
return segmentsOf(schedule, 'hour').length === 1 ?
|
|
1397
|
+
hourSegmentTimes(schedule, 0, null, opts) :
|
|
1398
|
+
hourSegmentTimesWithSeka(schedule, 0, null, opts);
|
|
1406
1399
|
}
|
|
1407
1400
|
|
|
1408
1401
|
// Render an hour range (or a list whose segments include a range) under
|
|
@@ -1411,24 +1404,25 @@ function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
|
|
|
1411
1404
|
// clock times. The hour-RANGE analog of hourCadence. Returns null when the
|
|
1412
1405
|
// hour has no range, when the minute is non-zero (a real clock minute the
|
|
1413
1406
|
// existing window form already speaks), or when a plain :00 set carries no
|
|
1414
|
-
// clause. Renderer-only; the
|
|
1415
|
-
function hourRangeCadence(
|
|
1407
|
+
// clause. Renderer-only; the Schedule is unchanged.
|
|
1408
|
+
function hourRangeCadence(schedule: Schedule, minute: number,
|
|
1416
1409
|
opts: NormalizedOptions): string | null {
|
|
1417
|
-
if (minute !== 0 || !hasHourWindow(
|
|
1410
|
+
if (minute !== 0 || !hasHourWindow(schedule) ||
|
|
1411
|
+
schedule.pattern.second === '0') {
|
|
1418
1412
|
return null;
|
|
1419
1413
|
}
|
|
1420
1414
|
|
|
1421
|
-
const tail = hourRangeWindowTail(
|
|
1415
|
+
const tail = hourRangeWindowTail(schedule, opts);
|
|
1422
1416
|
|
|
1423
1417
|
// A wildcard or sub-minute step second is the whole minute-0 window
|
|
1424
1418
|
// ("minuutin ajan", carried by hourCadenceLead), then the window — kept
|
|
1425
1419
|
// distinct from the bare "joka tunti klo 9–17" so the confinement is never
|
|
1426
1420
|
// heard as it (the hour-range analog of "minuutin ajan joka toisen tunnin
|
|
1427
1421
|
// aikana"). A meaningful second leads at its mark, then the window.
|
|
1428
|
-
const joiner = subMinuteSecond(
|
|
1422
|
+
const joiner = subMinuteSecond(schedule) ? ' ' : ', ';
|
|
1429
1423
|
|
|
1430
|
-
return hourCadenceLead(
|
|
1431
|
-
trailingQualifier(
|
|
1424
|
+
return hourCadenceLead(schedule, minute, opts) + joiner + tail +
|
|
1425
|
+
trailingQualifier(schedule, opts);
|
|
1432
1426
|
}
|
|
1433
1427
|
|
|
1434
1428
|
// --- Hour-time phrasing. ---
|
|
@@ -1447,7 +1441,7 @@ function kloList(hours: number[], opts: NormalizedOptions): string {
|
|
|
1447
1441
|
// The hour times accompanying a lead clause, with long expansions
|
|
1448
1442
|
// rendered segment by segment.
|
|
1449
1443
|
function kloFromTimes(
|
|
1450
|
-
|
|
1444
|
+
schedule: Schedule,
|
|
1451
1445
|
times: HourTimesPlan,
|
|
1452
1446
|
opts: NormalizedOptions
|
|
1453
1447
|
): string {
|
|
@@ -1455,7 +1449,7 @@ function kloFromTimes(
|
|
|
1455
1449
|
return kloList(times.fires, opts);
|
|
1456
1450
|
}
|
|
1457
1451
|
|
|
1458
|
-
return hourSegmentTimes(
|
|
1452
|
+
return hourSegmentTimes(schedule, 0, null, opts);
|
|
1459
1453
|
}
|
|
1460
1454
|
|
|
1461
1455
|
// The hours accompanying a named-once minute clause under an hour list or
|
|
@@ -1465,7 +1459,7 @@ function kloFromTimes(
|
|
|
1465
1459
|
// per-segment window ("klo 8.00–18.59 ja 22.00–22.59"), mirroring the other
|
|
1466
1460
|
// languages, which list discrete hours but keep range windows.
|
|
1467
1461
|
function hourWindowsFromTimes(
|
|
1468
|
-
|
|
1462
|
+
schedule: Schedule,
|
|
1469
1463
|
times: HourTimesPlan,
|
|
1470
1464
|
opts: NormalizedOptions
|
|
1471
1465
|
): string {
|
|
@@ -1473,7 +1467,7 @@ function hourWindowsFromTimes(
|
|
|
1473
1467
|
return kloList(times.fires, opts);
|
|
1474
1468
|
}
|
|
1475
1469
|
|
|
1476
|
-
const segments =
|
|
1470
|
+
const segments = segmentsOf(schedule, 'hour');
|
|
1477
1471
|
|
|
1478
1472
|
if (!segments.some(function ranged(segment: Segment) {
|
|
1479
1473
|
return segment.kind === 'range';
|
|
@@ -1527,14 +1521,14 @@ function hourWindowDigits(hour: number, opts: NormalizedOptions): string {
|
|
|
1527
1521
|
// klo, the minute (and optional second) folded into each:
|
|
1528
1522
|
// "klo 9.30–20.30 ja 22.30".
|
|
1529
1523
|
function hourSegmentTimes(
|
|
1530
|
-
|
|
1524
|
+
schedule: Schedule,
|
|
1531
1525
|
minute: number,
|
|
1532
1526
|
second: number | null | undefined,
|
|
1533
1527
|
opts: NormalizedOptions
|
|
1534
1528
|
): string {
|
|
1535
1529
|
const pieces: string[] = [];
|
|
1536
1530
|
|
|
1537
|
-
|
|
1531
|
+
segmentsOf(schedule, 'hour').forEach(function clock(segment: Segment) {
|
|
1538
1532
|
if (segment.kind === 'step') {
|
|
1539
1533
|
pieces.push(...segment.fires.map(function each(hour: number) {
|
|
1540
1534
|
return timeDigits(hour, minute, second, opts);
|
|
@@ -1626,30 +1620,30 @@ function timeDigits(
|
|
|
1626
1620
|
|
|
1627
1621
|
// The qualifier that precedes clock times: "joka päivä ",
|
|
1628
1622
|
// "maanantaisin ", "kuukauden 13. päivänä ".
|
|
1629
|
-
function leadingQualifier(
|
|
1630
|
-
const pattern =
|
|
1623
|
+
function leadingQualifier(schedule: Schedule, opts: NormalizedOptions): string {
|
|
1624
|
+
const pattern = schedule.pattern;
|
|
1631
1625
|
|
|
1632
1626
|
// When a restricted-month union is active, describe() assembles the full
|
|
1633
1627
|
// compound; suppress the qualifier here so render() returns only the
|
|
1634
1628
|
// time/frequency part.
|
|
1635
|
-
if (restrictedMonthUnion(
|
|
1629
|
+
if (restrictedMonthUnion(schedule)) {
|
|
1636
1630
|
return '';
|
|
1637
1631
|
}
|
|
1638
1632
|
|
|
1639
1633
|
if (pattern.date !== '*' && pattern.weekday !== '*') {
|
|
1640
|
-
return dateOrWeekday(
|
|
1634
|
+
return dateOrWeekday(schedule, opts) + ' ';
|
|
1641
1635
|
}
|
|
1642
1636
|
|
|
1643
1637
|
if (pattern.date !== '*') {
|
|
1644
|
-
return datePhrase(
|
|
1638
|
+
return datePhrase(schedule, opts) + ' ';
|
|
1645
1639
|
}
|
|
1646
1640
|
|
|
1647
1641
|
if (pattern.weekday !== '*') {
|
|
1648
|
-
return weekdayQualifier(
|
|
1642
|
+
return weekdayQualifier(schedule) + monthScope(schedule) + ' ';
|
|
1649
1643
|
}
|
|
1650
1644
|
|
|
1651
1645
|
if (pattern.month !== '*') {
|
|
1652
|
-
return 'joka päivä ' + monthPhrase(
|
|
1646
|
+
return 'joka päivä ' + monthPhrase(schedule) + ' ';
|
|
1653
1647
|
}
|
|
1654
1648
|
|
|
1655
1649
|
return 'joka päivä ';
|
|
@@ -1657,57 +1651,64 @@ function leadingQualifier(ir: IR, opts: NormalizedOptions): string {
|
|
|
1657
1651
|
|
|
1658
1652
|
// The qualifier trailing a frequency: " maanantaisin", " kesäkuussa",
|
|
1659
1653
|
// " kuukauden 13. päivänä". Empty when no day-level field is set.
|
|
1660
|
-
function trailingQualifier(
|
|
1661
|
-
|
|
1654
|
+
function trailingQualifier(
|
|
1655
|
+
schedule: Schedule, opts: NormalizedOptions
|
|
1656
|
+
): string {
|
|
1657
|
+
const pattern = schedule.pattern;
|
|
1662
1658
|
|
|
1663
1659
|
// When a restricted-month union is active, describe() assembles the full
|
|
1664
1660
|
// compound; suppress the qualifier here so render() returns only the
|
|
1665
1661
|
// time/frequency part.
|
|
1666
|
-
if (restrictedMonthUnion(
|
|
1662
|
+
if (restrictedMonthUnion(schedule)) {
|
|
1667
1663
|
return '';
|
|
1668
1664
|
}
|
|
1669
1665
|
|
|
1670
1666
|
if (pattern.date !== '*' && pattern.weekday !== '*') {
|
|
1671
|
-
return ' ' + dateOrWeekday(
|
|
1667
|
+
return ' ' + dateOrWeekday(schedule, opts);
|
|
1672
1668
|
}
|
|
1673
1669
|
|
|
1674
1670
|
if (pattern.date !== '*') {
|
|
1675
|
-
return ' ' + datePhrase(
|
|
1671
|
+
return ' ' + datePhrase(schedule, opts);
|
|
1676
1672
|
}
|
|
1677
1673
|
|
|
1678
1674
|
if (pattern.weekday !== '*') {
|
|
1679
|
-
return ' ' + weekdayQualifier(
|
|
1675
|
+
return ' ' + weekdayQualifier(schedule) + monthScope(schedule);
|
|
1680
1676
|
}
|
|
1681
1677
|
|
|
1682
1678
|
if (pattern.month !== '*') {
|
|
1683
|
-
return ' ' + monthPhrase(
|
|
1679
|
+
return ' ' + monthPhrase(schedule);
|
|
1684
1680
|
}
|
|
1685
1681
|
|
|
1686
1682
|
return '';
|
|
1687
1683
|
}
|
|
1688
1684
|
|
|
1689
1685
|
// "kuukauden 13. päivänä tai perjantaisin": cron fires when either the
|
|
1690
|
-
// date or the weekday matches. Only reachable when date≠*
|
|
1691
|
-
// AND month=* (the restricted-month union is handled in
|
|
1692
|
-
// so monthScope always returns '' here.
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1686
|
+
// date or the weekday matches (inclusive union). Only reachable when date≠*
|
|
1687
|
+
// AND weekday≠* AND month=* (the restricted-month union is handled in
|
|
1688
|
+
// describe()), so monthScope always returns '' here. An open `*/2` date
|
|
1689
|
+
// reads as the odd-day parity class (not the continuous "joka toinen
|
|
1690
|
+
// päivä"); a Mon–Fri weekday reads as the recurring class "arkisin".
|
|
1691
|
+
function dateOrWeekday(schedule: Schedule, opts: NormalizedOptions): string {
|
|
1692
|
+
const dateArm = oddDayUnion(schedule.pattern.date) ||
|
|
1693
|
+
datePhrase(schedule, opts);
|
|
1694
|
+
|
|
1695
|
+
return dateArm + ' tai ' + unionWeekdayArm(schedule) +
|
|
1696
|
+
monthScope(schedule);
|
|
1696
1697
|
}
|
|
1697
1698
|
|
|
1698
1699
|
// The weekday qualifier: distributive lists ("maanantaisin,
|
|
1699
1700
|
// keskiviikkoisin ja perjantaisin") and elative–illative ranges
|
|
1700
1701
|
// ("maanantaista perjantaihin"). Step segments flatten into their fires.
|
|
1701
|
-
function weekdayQualifier(
|
|
1702
|
-
const quartz = quartzWeekdayPhrase(
|
|
1702
|
+
function weekdayQualifier(schedule: Schedule): string {
|
|
1703
|
+
const quartz = quartzWeekdayPhrase(schedule.pattern.weekday);
|
|
1703
1704
|
|
|
1704
1705
|
if (quartz) {
|
|
1705
1706
|
return quartz;
|
|
1706
1707
|
}
|
|
1707
1708
|
|
|
1708
1709
|
// Weekday lists display Monday-first (Sunday last); a lone range keeps its
|
|
1709
|
-
// form. The
|
|
1710
|
-
const segments = orderWeekdaysForDisplay(
|
|
1710
|
+
// form. The Schedule stays canonical (Sunday=0). The helper flattens steps.
|
|
1711
|
+
const segments = orderWeekdaysForDisplay(segmentsOf(schedule, 'weekday'));
|
|
1711
1712
|
|
|
1712
1713
|
return joinList(segments.map(function piece(segment: FlatSegment) {
|
|
1713
1714
|
if (segment.kind === 'range') {
|
|
@@ -1722,8 +1723,8 @@ function weekdayQualifier(ir: IR): string {
|
|
|
1722
1723
|
// The month qualifier: inessive names ("kesäkuussa ja joulukuussa") and
|
|
1723
1724
|
// elative–illative ranges ("kesäkuusta syyskuuhun"). The case endings
|
|
1724
1725
|
// keep mixed lists unambiguous with no preposition bookkeeping.
|
|
1725
|
-
function monthPhrase(
|
|
1726
|
-
const segments = flattenSteps(
|
|
1726
|
+
function monthPhrase(schedule: Schedule): string {
|
|
1727
|
+
const segments = flattenSteps(segmentsOf(schedule, 'month'));
|
|
1727
1728
|
|
|
1728
1729
|
return joinList(segments.map(function piece(segment: FlatSegment) {
|
|
1729
1730
|
if (segment.kind === 'range') {
|
|
@@ -1737,12 +1738,12 @@ function monthPhrase(ir: IR): string {
|
|
|
1737
1738
|
|
|
1738
1739
|
// A trailing month scope on weekday qualifiers ("maanantaisin
|
|
1739
1740
|
// kesäkuussa").
|
|
1740
|
-
function monthScope(
|
|
1741
|
-
if (
|
|
1741
|
+
function monthScope(schedule: Schedule): string {
|
|
1742
|
+
if (schedule.pattern.month === '*') {
|
|
1742
1743
|
return '';
|
|
1743
1744
|
}
|
|
1744
1745
|
|
|
1745
|
-
return ' ' + monthPhrase(
|
|
1746
|
+
return ' ' + monthPhrase(schedule);
|
|
1746
1747
|
}
|
|
1747
1748
|
|
|
1748
1749
|
// Expand step segments into their fires as singles: the flat fires read
|
|
@@ -1760,20 +1761,21 @@ function flattenSteps(segments: Segment[]): FlatSegment[] {
|
|
|
1760
1761
|
// The date qualifier: "kuukauden 13. päivänä", "tammikuun 1. päivänä",
|
|
1761
1762
|
// "joka kolmannen kuukauden 1. päivänä", or a Quartz phrase. A foldable
|
|
1762
1763
|
// single year joins the date ("joulukuun 25. päivänä vuonna 2030").
|
|
1763
|
-
function datePhrase(
|
|
1764
|
-
const pattern =
|
|
1764
|
+
function datePhrase(schedule: Schedule, opts: NormalizedOptions): string {
|
|
1765
|
+
const pattern = schedule.pattern;
|
|
1765
1766
|
const quartz = quartzDatePhrase(pattern.date);
|
|
1766
1767
|
|
|
1767
1768
|
if (quartz) {
|
|
1768
|
-
return quartz + monthScope(
|
|
1769
|
+
return quartz + monthScope(schedule);
|
|
1769
1770
|
}
|
|
1770
1771
|
|
|
1771
1772
|
if (isOpenStep(pattern.date)) {
|
|
1772
|
-
return stepDates(pattern.date, opts) + monthScope(
|
|
1773
|
+
return stepDates(pattern.date, opts) + monthScope(schedule);
|
|
1773
1774
|
}
|
|
1774
1775
|
|
|
1775
|
-
return monthAnchor(
|
|
1776
|
-
foldedYear(
|
|
1776
|
+
return monthAnchor(schedule, opts) + ' ' + dateWords(schedule) + ' päivänä' +
|
|
1777
|
+
foldedYear(schedule) + monthStepStart(pattern.month) +
|
|
1778
|
+
rangedMonthScope(schedule);
|
|
1777
1779
|
}
|
|
1778
1780
|
|
|
1779
1781
|
// " helmikuusta alkaen" trailing the date words when an open month step
|
|
@@ -1797,10 +1799,10 @@ function monthStepStart(monthField: string): string {
|
|
|
1797
1799
|
// "tammikuun", "kesäkuun ja joulukuun", or "joka kolmannen kuukauden". A
|
|
1798
1800
|
// ranged month cannot take the genitive, so it scopes the date from
|
|
1799
1801
|
// behind instead (rangedMonthScope).
|
|
1800
|
-
function monthAnchor(
|
|
1801
|
-
const monthField =
|
|
1802
|
+
function monthAnchor(schedule: Schedule, opts: NormalizedOptions): string {
|
|
1803
|
+
const monthField = schedule.pattern.month;
|
|
1802
1804
|
|
|
1803
|
-
if (monthField === '*' || monthRanged(
|
|
1805
|
+
if (monthField === '*' || monthRanged(schedule)) {
|
|
1804
1806
|
return 'kuukauden';
|
|
1805
1807
|
}
|
|
1806
1808
|
|
|
@@ -1808,7 +1810,7 @@ function monthAnchor(ir: IR, opts: NormalizedOptions): string {
|
|
|
1808
1810
|
return stepMonths(monthField, opts);
|
|
1809
1811
|
}
|
|
1810
1812
|
|
|
1811
|
-
const segments = flattenSteps(
|
|
1813
|
+
const segments = flattenSteps(segmentsOf(schedule, 'month'));
|
|
1812
1814
|
|
|
1813
1815
|
return joinList(segments.map(function genitiveOf(segment: FlatSegment) {
|
|
1814
1816
|
// The anchor branch is only reached for non-ranged months, so every
|
|
@@ -1820,22 +1822,22 @@ function monthAnchor(ir: IR, opts: NormalizedOptions): string {
|
|
|
1820
1822
|
}
|
|
1821
1823
|
|
|
1822
1824
|
// " kesäkuusta syyskuuhun" trailing a date under a ranged month.
|
|
1823
|
-
function rangedMonthScope(
|
|
1824
|
-
return monthRanged(
|
|
1825
|
+
function rangedMonthScope(schedule: Schedule): string {
|
|
1826
|
+
return monthRanged(schedule) ? ' ' + monthPhrase(schedule) : '';
|
|
1825
1827
|
}
|
|
1826
1828
|
|
|
1827
1829
|
// Whether the month field contains a range segment.
|
|
1828
|
-
function monthRanged(
|
|
1829
|
-
return
|
|
1830
|
-
|
|
1830
|
+
function monthRanged(schedule: Schedule): boolean {
|
|
1831
|
+
return schedule.pattern.month !== '*' &&
|
|
1832
|
+
segmentsOf(schedule, 'month').some(function range(segment: Segment) {
|
|
1831
1833
|
return segment.kind === 'range';
|
|
1832
1834
|
});
|
|
1833
1835
|
}
|
|
1834
1836
|
|
|
1835
1837
|
// The day-of-month words: "13.", "1. ja 15.", "1.–15.", with step
|
|
1836
1838
|
// segments expanded into their fires.
|
|
1837
|
-
function dateWords(
|
|
1838
|
-
return joinList(
|
|
1839
|
+
function dateWords(schedule: Schedule): string {
|
|
1840
|
+
return joinList(segmentsOf(schedule, 'date').flatMap(
|
|
1839
1841
|
function word(segment: Segment): string[] {
|
|
1840
1842
|
if (segment.kind === 'range') {
|
|
1841
1843
|
return [segment.bounds[0] + '.–' + segment.bounds[1] + '.'];
|
|
@@ -1932,10 +1934,10 @@ function monthNumber(token: string | number): number {
|
|
|
1932
1934
|
// rendered.
|
|
1933
1935
|
function applyYear(
|
|
1934
1936
|
description: string,
|
|
1935
|
-
|
|
1937
|
+
schedule: Schedule,
|
|
1936
1938
|
opts: NormalizedOptions
|
|
1937
1939
|
): string {
|
|
1938
|
-
const yearField =
|
|
1940
|
+
const yearField = schedule.pattern.year;
|
|
1939
1941
|
|
|
1940
1942
|
if (yearField === '*') {
|
|
1941
1943
|
return description;
|
|
@@ -1946,7 +1948,7 @@ function applyYear(
|
|
|
1946
1948
|
}
|
|
1947
1949
|
|
|
1948
1950
|
// A foldable single year already joined its date in datePhrase.
|
|
1949
|
-
if (foldedYear(
|
|
1951
|
+
if (foldedYear(schedule) && schedule.pattern.date !== '*') {
|
|
1950
1952
|
return description;
|
|
1951
1953
|
}
|
|
1952
1954
|
|
|
@@ -1979,8 +1981,8 @@ function stepYears(yearField: string, opts: NormalizedOptions): string {
|
|
|
1979
1981
|
}
|
|
1980
1982
|
|
|
1981
1983
|
// " vuonna 2030" when a single year can fold into a calendar date.
|
|
1982
|
-
function foldedYear(
|
|
1983
|
-
const yearField =
|
|
1984
|
+
function foldedYear(schedule: Schedule): string {
|
|
1985
|
+
const yearField = schedule.pattern.year;
|
|
1984
1986
|
|
|
1985
1987
|
if (yearField === '*' || yearField.indexOf('/') !== -1 ||
|
|
1986
1988
|
yearField.indexOf('-') !== -1 || yearField.indexOf(',') !== -1) {
|
|
@@ -2008,13 +2010,6 @@ function segmentWords(segments: Segment[]): string[] {
|
|
|
2008
2010
|
});
|
|
2009
2011
|
}
|
|
2010
2012
|
|
|
2011
|
-
// Whether a canonical field value is an "open" step (`*/n` or `a/n`, not
|
|
2012
|
-
// a bounded range or a list). Open steps read as a frequency rather than
|
|
2013
|
-
// an enumeration.
|
|
2014
|
-
function isOpenStep(field: string): boolean {
|
|
2015
|
-
return field.indexOf('/') !== -1 && field.indexOf('-') === -1 &&
|
|
2016
|
-
field.indexOf(',') === -1;
|
|
2017
|
-
}
|
|
2018
2013
|
|
|
2019
2014
|
// Numeric fire values as digits.
|
|
2020
2015
|
function wordList(fires: number[]): string[] {
|
|
@@ -2058,7 +2053,7 @@ function joinList(items: string[]): string {
|
|
|
2058
2053
|
return items.slice(0, -1).join(', ') + ' ja ' + items[items.length - 1];
|
|
2059
2054
|
}
|
|
2060
2055
|
|
|
2061
|
-
// The Finnish language module: the
|
|
2056
|
+
// The Finnish language module: the Schedule renderer plus the language-owned
|
|
2062
2057
|
// strings and option normalization.
|
|
2063
2058
|
const fi: Language = {
|
|
2064
2059
|
describe,
|