cronli5 0.1.4 → 0.1.6
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 +53 -0
- package/cronli5.min.js +2 -2
- package/dist/cronli5.cjs +286 -45
- package/dist/cronli5.js +286 -45
- package/dist/lang/de.cjs +252 -13
- package/dist/lang/de.js +252 -13
- package/dist/lang/en.cjs +281 -38
- package/dist/lang/en.js +281 -38
- package/dist/lang/es.cjs +259 -29
- package/dist/lang/es.js +259 -29
- package/dist/lang/fi.cjs +285 -49
- package/dist/lang/fi.js +285 -49
- package/dist/lang/zh.cjs +225 -42
- package/dist/lang/zh.js +225 -42
- package/package.json +3 -2
- package/src/core/analyze.ts +7 -0
- package/src/core/ir.ts +1 -1
- package/src/core/util.ts +31 -1
- package/src/lang/de/index.ts +561 -30
- package/src/lang/en/index.ts +593 -59
- package/src/lang/es/index.ts +576 -52
- package/src/lang/fi/index.ts +633 -95
- package/src/lang/zh/index.ts +484 -77
- package/types/core/ir.d.ts +1 -1
- package/types/core/util.d.ts +6 -1
package/src/lang/fi/index.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// case-pair construction wherever digits appear.
|
|
11
11
|
|
|
12
12
|
import {clockDigits, numeral} from '../../core/format.js';
|
|
13
|
-
import {weekdayNumbers} from '../../core/specs.js';
|
|
14
|
-
import {toFieldNumber} from '../../core/util.js';
|
|
13
|
+
import {maxClockTimes, weekdayNumbers} from '../../core/specs.js';
|
|
14
|
+
import {arithmeticStep, toFieldNumber} from '../../core/util.js';
|
|
15
15
|
import {resolveDialect} from './dialects.js';
|
|
16
16
|
import type {
|
|
17
17
|
ClockTime, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
|
|
@@ -63,6 +63,7 @@ interface UnitForms {
|
|
|
63
63
|
mark: string;
|
|
64
64
|
anchor: string;
|
|
65
65
|
ela: string;
|
|
66
|
+
ill: string;
|
|
66
67
|
gen: string;
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -170,12 +171,14 @@ const units: {minute: UnitForms; second: UnitForms} = {
|
|
|
170
171
|
mark: 'joka tunti',
|
|
171
172
|
anchor: 'jokaisen tunnin',
|
|
172
173
|
ela: 'minuutista',
|
|
174
|
+
ill: 'minuuttiin',
|
|
173
175
|
gen: 'minuutin'
|
|
174
176
|
},
|
|
175
177
|
second: {
|
|
176
178
|
mark: 'joka minuutti',
|
|
177
179
|
anchor: 'jokaisen minuutin',
|
|
178
180
|
ela: 'sekunnista',
|
|
181
|
+
ill: 'sekuntiin',
|
|
179
182
|
gen: 'sekunnin'
|
|
180
183
|
}
|
|
181
184
|
};
|
|
@@ -296,49 +299,89 @@ function renderSecondsWithinMinute(
|
|
|
296
299
|
atMarks(minuteField, units.minute, true) + trailingQualifier(ir, opts);
|
|
297
300
|
}
|
|
298
301
|
|
|
302
|
+
// A meaningful second composed over a minute-step cadence: the step leads and
|
|
303
|
+
// the second anchor follows after a comma, with the hour clause interleaved
|
|
304
|
+
// between them ("[step], [seconds][hour clause][trailing qualifier]"). The
|
|
305
|
+
// minute-frequency phrase is reconstructed directly here so the hour clause can
|
|
306
|
+
// sit between the step and the second anchor without duplicating the full
|
|
307
|
+
// renderMinuteFrequency logic; its hours-first reorder is intentionally NOT
|
|
308
|
+
// applied (the step-leads form is the correct shape for this construction).
|
|
309
|
+
function composeSecondsOverMinuteStep(
|
|
310
|
+
ir: IR,
|
|
311
|
+
freq: Extract<PlanNode, {kind: 'minuteFrequency'}>,
|
|
312
|
+
opts: NormalizedOptions
|
|
313
|
+
): string {
|
|
314
|
+
const seg = stepSegment(ir.analyses.segments.minute!);
|
|
315
|
+
const stepPhrase = stepCycle60(seg, units.minute, opts);
|
|
316
|
+
|
|
317
|
+
if (freq.hours.kind === 'during' && minuteStepIsAnchored(seg)) {
|
|
318
|
+
// The step renders as an anchored kohdalla list rather than a cadence, so
|
|
319
|
+
// the hours-first reorder applies here too: bare hours lead, minute anchors
|
|
320
|
+
// follow, then the seconds clause.
|
|
321
|
+
const bareHours = kloFromTimes(ir, freq.hours.times, opts);
|
|
322
|
+
|
|
323
|
+
return hoursFirstMinutes(bareHours, ir, opts) + ', ' +
|
|
324
|
+
secondsLeadClause(ir, opts) + trailingQualifier(ir, opts);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let hourClause = '';
|
|
328
|
+
|
|
329
|
+
if (freq.hours.kind === 'during') {
|
|
330
|
+
hourClause = ' ' + hourWindowsFromTimes(ir, freq.hours.times, opts);
|
|
331
|
+
}
|
|
332
|
+
else if (freq.hours.kind === 'window') {
|
|
333
|
+
hourClause = ' ' + hourWindow(freq.hours, opts);
|
|
334
|
+
}
|
|
335
|
+
else if (freq.hours.kind === 'step') {
|
|
336
|
+
hourClause = ' ' +
|
|
337
|
+
everyNthHour(stepSegment(ir.analyses.segments.hour!), opts);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return stepPhrase + ', ' + secondsLeadClause(ir, opts) +
|
|
341
|
+
hourClause + trailingQualifier(ir, opts);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The hour-cadence rendering of a compose-seconds plan whose clock-time rest
|
|
345
|
+
// would cross-multiply an hour stride under a single pinned minute, or null
|
|
346
|
+
// when that does not apply (a non-clock rest, a multi-valued minute, or an
|
|
347
|
+
// hour that is not a stride).
|
|
348
|
+
function composeHourCadence(
|
|
349
|
+
ir: IR,
|
|
350
|
+
plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
|
|
351
|
+
opts: NormalizedOptions
|
|
352
|
+
): string | null {
|
|
353
|
+
const clockRest = plan.rest.kind === 'clockTimes' ||
|
|
354
|
+
plan.rest.kind === 'compactClockTimes';
|
|
355
|
+
|
|
356
|
+
if (!clockRest || ir.shapes.minute !== 'single') {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const minute = +ir.pattern.minute;
|
|
361
|
+
|
|
362
|
+
return hourCadence(ir, minute, opts) ?? hourRangeCadence(ir, minute, opts);
|
|
363
|
+
}
|
|
364
|
+
|
|
299
365
|
function renderComposeSeconds(
|
|
300
366
|
ir: IR,
|
|
301
367
|
plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
|
|
302
368
|
opts: NormalizedOptions
|
|
303
369
|
): string {
|
|
370
|
+
// An hour step (or arithmetic-progression hour list) under a single pinned
|
|
371
|
+
// minute is a cadence, not a wall of clock times: the second/minute lead,
|
|
372
|
+
// then the hour cadence ("30 sekunnin kohdalla, kahden tunnin välein"). The
|
|
373
|
+
// clock-time rest would otherwise cross-multiply the hours.
|
|
374
|
+
const cadence = composeHourCadence(ir, plan, opts);
|
|
375
|
+
|
|
376
|
+
if (cadence !== null) {
|
|
377
|
+
return cadence;
|
|
378
|
+
}
|
|
379
|
+
|
|
304
380
|
// When the rest is a minute-step cadence, the step leads and the second
|
|
305
381
|
// anchor follows after a comma (the comma marks the granularity boundary
|
|
306
|
-
// between the two levels, not a flat list).
|
|
307
|
-
// "[step phrase], [seconds][hour clause][trailing qualifier]".
|
|
308
|
-
//
|
|
309
|
-
// The minute-frequency phrase is reconstructed directly here so the hour
|
|
310
|
-
// clause can be interleaved between the step and the second anchor without
|
|
311
|
-
// duplicating the full renderMinuteFrequency logic. The hours-first reorder
|
|
312
|
-
// that applies inside renderMinuteFrequency is intentionally NOT applied
|
|
313
|
-
// here (the step-leads form is the correct shape for this construction).
|
|
382
|
+
// between the two levels, not a flat list).
|
|
314
383
|
if (plan.rest.kind === 'minuteFrequency' && ir.pattern.second !== '*') {
|
|
315
|
-
|
|
316
|
-
const seg = stepSegment(ir.analyses.segments.minute!);
|
|
317
|
-
const stepPhrase = stepCycle60(seg, units.minute, opts);
|
|
318
|
-
let hourClause = '';
|
|
319
|
-
|
|
320
|
-
if (freq.hours.kind === 'during' && minuteStepIsAnchored(seg)) {
|
|
321
|
-
// The step renders as an anchored kohdalla list rather than a cadence,
|
|
322
|
-
// so the hours-first reorder applies here too: bare hours lead, minute
|
|
323
|
-
// anchors follow, then the seconds clause.
|
|
324
|
-
const bareHours = kloFromTimes(ir, freq.hours.times, opts);
|
|
325
|
-
|
|
326
|
-
return hoursFirstMinutes(bareHours, ir) + ', ' +
|
|
327
|
-
secondsLeadClause(ir, opts) + trailingQualifier(ir, opts);
|
|
328
|
-
}
|
|
329
|
-
else if (freq.hours.kind === 'during' && !minuteStepIsAnchored(seg)) {
|
|
330
|
-
hourClause = ' ' + hourWindowsFromTimes(ir, freq.hours.times, opts);
|
|
331
|
-
}
|
|
332
|
-
else if (freq.hours.kind === 'window') {
|
|
333
|
-
hourClause = ' ' + hourWindow(freq.hours, opts);
|
|
334
|
-
}
|
|
335
|
-
else if (freq.hours.kind === 'step') {
|
|
336
|
-
hourClause = ' ' +
|
|
337
|
-
everyNthHour(stepSegment(ir.analyses.segments.hour!), opts);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return stepPhrase + ', ' + secondsLeadClause(ir, opts) +
|
|
341
|
-
hourClause + trailingQualifier(ir, opts);
|
|
384
|
+
return composeSecondsOverMinuteStep(ir, plan.rest, opts);
|
|
342
385
|
}
|
|
343
386
|
|
|
344
387
|
// A sub-minute second with the minute pinned to 0 and a specific hour: the
|
|
@@ -352,7 +395,40 @@ function renderComposeSeconds(
|
|
|
352
395
|
return composeMinuteZero(ir, plan.rest, opts);
|
|
353
396
|
}
|
|
354
397
|
|
|
355
|
-
|
|
398
|
+
// A wildcard second under a minute */2 with a wildcard hour juxtaposes two
|
|
399
|
+
// cadences that read as contradictory ("joka sekunti, kahden minuutin
|
|
400
|
+
// välein"). Bind them as "every second of every other minute" ("joka sekunti
|
|
401
|
+
// joka toisena minuuttina"), mirroring English. Other strides, a restricted
|
|
402
|
+
// hour, and an hour cadence keep the juxtaposed form.
|
|
403
|
+
if (isEveryOtherMinuteSeconds(ir, plan)) {
|
|
404
|
+
return secondsLeadClause(ir, opts) + ' joka toisena minuuttina';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// A compact clock-time rest folds a meaningful SINGLE second into its own
|
|
408
|
+
// leading clause, so the composer must not prepend a second lead that would
|
|
409
|
+
// double it. A wildcard or stepped second is not folded there (no
|
|
410
|
+
// clockSecond), so it still leads its own clause here.
|
|
411
|
+
const restOwnsLead = plan.rest.kind === 'compactClockTimes' &&
|
|
412
|
+
ir.analyses.clockSecond;
|
|
413
|
+
const lead = restOwnsLead ? '' : secondsLeadClause(ir, opts) + ', ';
|
|
414
|
+
|
|
415
|
+
return lead + render(ir, plan.rest, opts);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// A wildcard second over an unoffset minute */2 with a wildcard hour: the two
|
|
419
|
+
// cadences read as contradictory side by side, so they bind into one.
|
|
420
|
+
function isEveryOtherMinuteSeconds(
|
|
421
|
+
ir: IR,
|
|
422
|
+
plan: Extract<PlanNode, {kind: 'composeSeconds'}>
|
|
423
|
+
): boolean {
|
|
424
|
+
if (plan.rest.kind !== 'minuteFrequency' || ir.pattern.second !== '*' ||
|
|
425
|
+
ir.shapes.hour !== 'wildcard') {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const seg = stepSegment(ir.analyses.segments.minute!);
|
|
430
|
+
|
|
431
|
+
return seg.startToken === '*' && seg.interval === 2;
|
|
356
432
|
}
|
|
357
433
|
|
|
358
434
|
// The minute-0 confinement: bind the seconds to the explicit clock minute(s)
|
|
@@ -400,8 +476,11 @@ function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
|
|
|
400
476
|
return atMarks(secondField, units.second, marked);
|
|
401
477
|
}
|
|
402
478
|
|
|
403
|
-
|
|
404
|
-
|
|
479
|
+
// An offset/uneven step the core enumerated to this list reads as a stride
|
|
480
|
+
// cadence when the fires form a long-enough progression.
|
|
481
|
+
return strideFromSegments(ir.analyses.segments.second!, units.second, opts) ??
|
|
482
|
+
atMarks(joinList(segmentWords(ir.analyses.segments.second!)),
|
|
483
|
+
units.second, marked);
|
|
405
484
|
}
|
|
406
485
|
|
|
407
486
|
// --- Minute renderers. ---
|
|
@@ -428,7 +507,7 @@ function renderRangeOfMinutes(
|
|
|
428
507
|
plan: Extract<PlanNode, {kind: 'rangeOfMinutes'}>,
|
|
429
508
|
opts: NormalizedOptions
|
|
430
509
|
): string {
|
|
431
|
-
return minutesList(ir) + trailingQualifier(ir, opts);
|
|
510
|
+
return minutesList(ir, opts) + trailingQualifier(ir, opts);
|
|
432
511
|
}
|
|
433
512
|
|
|
434
513
|
function renderMultipleMinutes(
|
|
@@ -436,21 +515,27 @@ function renderMultipleMinutes(
|
|
|
436
515
|
plan: Extract<PlanNode, {kind: 'multipleMinutes'}>,
|
|
437
516
|
opts: NormalizedOptions
|
|
438
517
|
): string {
|
|
439
|
-
return minutesList(ir) + trailingQualifier(ir, opts);
|
|
518
|
+
return minutesList(ir, opts) + trailingQualifier(ir, opts);
|
|
440
519
|
}
|
|
441
520
|
|
|
442
|
-
// "joka tunti 0, 15 ja 30 minuutin kohdalla" (or a dash range).
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
521
|
+
// "joka tunti 0, 15 ja 30 minuutin kohdalla" (or a dash range). An offset/
|
|
522
|
+
// uneven step the core enumerated to this list reads as a stride cadence when
|
|
523
|
+
// the fires form a long-enough progression ("kahden minuutin välein
|
|
524
|
+
// minuutista 3 minuuttiin 59").
|
|
525
|
+
function minutesList(ir: IR, opts: NormalizedOptions): string {
|
|
526
|
+
return strideFromSegments(ir.analyses.segments.minute!, units.minute, opts) ??
|
|
527
|
+
atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
|
|
528
|
+
units.minute, true);
|
|
446
529
|
}
|
|
447
530
|
|
|
448
531
|
// The bare minute mark, for clauses where a specific hour follows and
|
|
449
532
|
// the "joka tunti" frequency would be redundant: "0–30 minuutin
|
|
450
|
-
// kohdalla".
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
533
|
+
// kohdalla". A progression reads as its bounded cadence (which carries no
|
|
534
|
+
// per-hour frequency to drop).
|
|
535
|
+
function bareMinutes(ir: IR, opts: NormalizedOptions): string {
|
|
536
|
+
return strideFromSegments(ir.analyses.segments.minute!, units.minute, opts) ??
|
|
537
|
+
atMarks(joinList(segmentWords(ir.analyses.segments.minute!)),
|
|
538
|
+
units.minute, false);
|
|
454
539
|
}
|
|
455
540
|
|
|
456
541
|
// Whether a minute step renders as an anchored "kohdalla" clause rather
|
|
@@ -500,7 +585,21 @@ function hoursAreRangeIsolated(segments: Segment[]): boolean {
|
|
|
500
585
|
// (plural genitive "minuuttien"; replaces the leading "joka tunti"). Used
|
|
501
586
|
// when a range or multi-point minute list over enumerated hours renders
|
|
502
587
|
// hours-first.
|
|
503
|
-
function hoursFirstMinutes(
|
|
588
|
+
function hoursFirstMinutes(
|
|
589
|
+
hoursStr: string,
|
|
590
|
+
ir: IR,
|
|
591
|
+
opts: NormalizedOptions
|
|
592
|
+
): string {
|
|
593
|
+
// An offset/uneven step the core enumerated to this list reads as a stride
|
|
594
|
+
// cadence ("aina kahden minuutin välein minuutista 3 minuuttiin 59") when
|
|
595
|
+
// the fires form a long-enough progression, rather than the kohdalla list.
|
|
596
|
+
const stride =
|
|
597
|
+
strideFromSegments(ir.analyses.segments.minute!, units.minute, opts);
|
|
598
|
+
|
|
599
|
+
if (stride) {
|
|
600
|
+
return hoursStr + ' aina ' + stride;
|
|
601
|
+
}
|
|
602
|
+
|
|
504
603
|
return hoursStr + ' aina minuuttien ' +
|
|
505
604
|
joinList(segmentWords(ir.analyses.segments.minute!)) + ' kohdalla';
|
|
506
605
|
}
|
|
@@ -540,13 +639,24 @@ function renderMinuteFrequency(
|
|
|
540
639
|
const seg = stepSegment(ir.analyses.segments.minute!);
|
|
541
640
|
|
|
542
641
|
if (plan.hours.kind === 'during') {
|
|
642
|
+
// A bounded or uneven hour stride reads as its endpoint-pinning cadence
|
|
643
|
+
// after the minute step ("15 minuutin välein, viiden tunnin välein klo
|
|
644
|
+
// 0–20").
|
|
645
|
+
const cadence = unevenHourCadence(ir, opts);
|
|
646
|
+
|
|
647
|
+
if (cadence !== null) {
|
|
648
|
+
return stepCycle60(seg, units.minute, opts) + ', ' + cadence +
|
|
649
|
+
trailingQualifier(ir, opts);
|
|
650
|
+
}
|
|
651
|
+
|
|
543
652
|
// When the step renders as anchored ("kohdalla"), the per-hour windows
|
|
544
653
|
// are redundant — use bare clock hours instead, then reorder to
|
|
545
654
|
// hours-first: "klo <hours> aina minuuttien <spec> kohdalla".
|
|
546
655
|
if (minuteStepIsAnchored(seg)) {
|
|
547
656
|
const bareHours = kloFromTimes(ir, plan.hours.times, opts);
|
|
548
657
|
|
|
549
|
-
return hoursFirstMinutes(bareHours, ir
|
|
658
|
+
return hoursFirstMinutes(bareHours, ir, opts) +
|
|
659
|
+
trailingQualifier(ir, opts);
|
|
550
660
|
}
|
|
551
661
|
|
|
552
662
|
return stepCycle60(seg, units.minute, opts) + ' ' +
|
|
@@ -599,14 +709,25 @@ function renderMinutesAcrossHours(
|
|
|
599
709
|
plan: Extract<PlanNode, {kind: 'minutesAcrossHours'}>,
|
|
600
710
|
opts: NormalizedOptions
|
|
601
711
|
): string {
|
|
712
|
+
// A bounded or uneven hour stride reads as its endpoint-pinning cadence after
|
|
713
|
+
// the minute clause ("joka minuutti, viiden tunnin välein klo 0–20"), not a
|
|
714
|
+
// wall of hour windows.
|
|
715
|
+
const cadence = unevenHourCadence(ir, opts);
|
|
716
|
+
|
|
602
717
|
if (plan.form === 'wildcard') {
|
|
603
|
-
return
|
|
604
|
-
trailingQualifier(ir, opts)
|
|
718
|
+
return cadence ?
|
|
719
|
+
'joka minuutti, ' + cadence + trailingQualifier(ir, opts) :
|
|
720
|
+
'joka minuutti ' + hourWindowsFromTimes(ir, plan.times, opts) +
|
|
721
|
+
trailingQualifier(ir, opts);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (cadence !== null) {
|
|
725
|
+
return bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts);
|
|
605
726
|
}
|
|
606
727
|
|
|
607
728
|
// Range+isolated hours: minute-first, bare minutes, sekä klo.
|
|
608
729
|
if (hoursAreRangeIsolated(ir.analyses.segments.hour!)) {
|
|
609
|
-
return bareMinutes(ir) + ' ' +
|
|
730
|
+
return bareMinutes(ir, opts) + ' ' +
|
|
610
731
|
hourSegmentTimesWithSeka(ir, 0, null, opts) +
|
|
611
732
|
trailingQualifier(ir, opts);
|
|
612
733
|
}
|
|
@@ -616,7 +737,7 @@ function renderMinutesAcrossHours(
|
|
|
616
737
|
// shows it).
|
|
617
738
|
const hoursStr = kloFromTimes(ir, plan.times, opts);
|
|
618
739
|
|
|
619
|
-
return hoursFirstMinutes(hoursStr, ir) + trailingQualifier(ir, opts);
|
|
740
|
+
return hoursFirstMinutes(hoursStr, ir, opts) + trailingQualifier(ir, opts);
|
|
620
741
|
}
|
|
621
742
|
|
|
622
743
|
function renderMinuteSpanAcrossHourStep(
|
|
@@ -626,28 +747,28 @@ function renderMinuteSpanAcrossHourStep(
|
|
|
626
747
|
): string {
|
|
627
748
|
// An hour-step plan's first hour segment is always a step segment.
|
|
628
749
|
const segment = stepSegment(ir.analyses.segments.hour!);
|
|
750
|
+
// A bounded or uneven hour stride reads as its endpoint-pinning cadence; an
|
|
751
|
+
// offset-clean stride keeps its confinement / per-step phrasing.
|
|
752
|
+
const cadence = unevenHourCadence(ir, opts);
|
|
629
753
|
|
|
630
754
|
// A wildcard span always sets the step off with a comma ("joka
|
|
631
755
|
// minuutti, joka toinen tunti"); a restricted span joins a plain step
|
|
632
756
|
// directly ("minuuteilla 0–30 joka toinen tunti").
|
|
633
|
-
// A wildcard minute (a cadence) is reached only for a clean step
|
|
634
|
-
//
|
|
635
|
-
//
|
|
757
|
+
// A wildcard minute (a cadence) is reached only for a clean step (a bounded
|
|
758
|
+
// or uneven step routes through minutesAcrossHours instead) and is confined
|
|
759
|
+
// to every Nth hour; a restricted span is a per-hour window + plain step.
|
|
636
760
|
if (plan.form === 'wildcard') {
|
|
637
761
|
return 'joka minuutti ' + everyNthHour(segment, opts) +
|
|
638
762
|
trailingQualifier(ir, opts);
|
|
639
763
|
}
|
|
640
764
|
|
|
641
|
-
// A bounded
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const hoursStr = kloList(segment.fires, opts);
|
|
646
|
-
|
|
647
|
-
return hoursFirstMinutes(hoursStr, ir) + trailingQualifier(ir, opts);
|
|
765
|
+
// A bounded or uneven stride reads as its bounded cadence after the bare
|
|
766
|
+
// minutes ("minuuteilla 0–30, kahden tunnin välein klo 9–17").
|
|
767
|
+
if (cadence !== null) {
|
|
768
|
+
return bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts);
|
|
648
769
|
}
|
|
649
770
|
|
|
650
|
-
return bareMinutes(ir) + hourStepTail(segment, opts) +
|
|
771
|
+
return bareMinutes(ir, opts) + hourStepTail(segment, opts) +
|
|
651
772
|
trailingQualifier(ir, opts);
|
|
652
773
|
}
|
|
653
774
|
|
|
@@ -725,7 +846,7 @@ function renderHourRange(
|
|
|
725
846
|
// A minute range over a single hour range renders hours-first
|
|
726
847
|
// ("klo 9.00–17.30 aina minuuttien 0–30 kohdalla").
|
|
727
848
|
if (plan.minuteForm === 'range') {
|
|
728
|
-
return hoursFirstMinutes(window, ir) + trailingQualifier(ir, opts);
|
|
849
|
+
return hoursFirstMinutes(window, ir, opts) + trailingQualifier(ir, opts);
|
|
729
850
|
}
|
|
730
851
|
|
|
731
852
|
// On the hour the window joins directly ("joka tunti klo 9–17"); a
|
|
@@ -744,7 +865,7 @@ function renderHourRange(
|
|
|
744
865
|
|
|
745
866
|
// A minute list (≥2 values) over a single hour range renders hours-first
|
|
746
867
|
// ("klo 9.00–17.30 aina minuuttien 0 ja 30 kohdalla").
|
|
747
|
-
return hoursFirstMinutes(window, ir) + trailingQualifier(ir, opts);
|
|
868
|
+
return hoursFirstMinutes(window, ir, opts) + trailingQualifier(ir, opts);
|
|
748
869
|
}
|
|
749
870
|
|
|
750
871
|
function renderHourStep(
|
|
@@ -752,6 +873,15 @@ function renderHourStep(
|
|
|
752
873
|
plan: Extract<PlanNode, {kind: 'hourStep'}>,
|
|
753
874
|
opts: NormalizedOptions
|
|
754
875
|
): string {
|
|
876
|
+
// A bounded or uneven hour step reads as its endpoint-pinning cadence
|
|
877
|
+
// ("kahden tunnin välein klo 9–17"); an offset-clean step keeps its bare or
|
|
878
|
+
// "alkaen" cadence.
|
|
879
|
+
const cadence = unevenHourCadence(ir, opts);
|
|
880
|
+
|
|
881
|
+
if (cadence !== null) {
|
|
882
|
+
return cadence + trailingQualifier(ir, opts);
|
|
883
|
+
}
|
|
884
|
+
|
|
755
885
|
return stepHours(stepSegment(ir.analyses.segments.hour!), opts) +
|
|
756
886
|
trailingQualifier(ir, opts);
|
|
757
887
|
}
|
|
@@ -779,6 +909,19 @@ function renderClockTimes(
|
|
|
779
909
|
plan: Extract<PlanNode, {kind: 'clockTimes'}>,
|
|
780
910
|
opts: NormalizedOptions
|
|
781
911
|
): string {
|
|
912
|
+
// An hour step or range (or arithmetic-progression hour list) under a single
|
|
913
|
+
// pinned minute reads as a cadence or window rather than a cross-product of
|
|
914
|
+
// clock times.
|
|
915
|
+
if (ir.shapes.minute === 'single') {
|
|
916
|
+
const minute = +ir.pattern.minute;
|
|
917
|
+
const cadence = hourCadence(ir, minute, opts) ??
|
|
918
|
+
hourRangeCadence(ir, minute, opts);
|
|
919
|
+
|
|
920
|
+
if (cadence !== null) {
|
|
921
|
+
return cadence;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
782
925
|
if (plan.times.length === 1) {
|
|
783
926
|
const time = plan.times[0];
|
|
784
927
|
|
|
@@ -802,6 +945,18 @@ function renderCompactClockTimes(
|
|
|
802
945
|
plan: Extract<PlanNode, {kind: 'compactClockTimes'}>,
|
|
803
946
|
opts: NormalizedOptions
|
|
804
947
|
): string {
|
|
948
|
+
// An hour step (or arithmetic-progression hour list) under the single pinned
|
|
949
|
+
// minute reads as a cadence, not a wall of clock times. (Returns null for an
|
|
950
|
+
// irregular list or a range, which keep folding below.)
|
|
951
|
+
if (plan.fold) {
|
|
952
|
+
const cadence = hourCadence(ir, plan.minute, opts) ??
|
|
953
|
+
hourRangeCadence(ir, plan.minute, opts);
|
|
954
|
+
|
|
955
|
+
if (cadence !== null) {
|
|
956
|
+
return cadence;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
805
960
|
const hourSegs = ir.analyses.segments.hour!;
|
|
806
961
|
|
|
807
962
|
// Range+isolated hours: join the isolated hour with "sekä klo" to stop it
|
|
@@ -815,7 +970,7 @@ function renderCompactClockTimes(
|
|
|
815
970
|
ir.analyses.clockSecond, opts);
|
|
816
971
|
}
|
|
817
972
|
|
|
818
|
-
const phrase = bareMinutes(ir) + ' ' +
|
|
973
|
+
const phrase = bareMinutes(ir, opts) + ' ' +
|
|
819
974
|
hourSegmentTimesWithSeka(ir, 0, null, opts) +
|
|
820
975
|
trailingQualifier(ir, opts);
|
|
821
976
|
|
|
@@ -829,11 +984,16 @@ function renderCompactClockTimes(
|
|
|
829
984
|
hourSegmentTimes(ir, plan.minute, ir.analyses.clockSecond, opts);
|
|
830
985
|
}
|
|
831
986
|
|
|
832
|
-
// A
|
|
833
|
-
//
|
|
834
|
-
|
|
835
|
-
const
|
|
836
|
-
|
|
987
|
+
// A bounded or uneven hour stride reads as its endpoint-pinning cadence after
|
|
988
|
+
// the bare minute clause ("minuuteilla 0, 25 ja 50, viiden tunnin välein klo
|
|
989
|
+
// 0–20"), not a wall of clock-time columns.
|
|
990
|
+
const cadence = unevenHourCadence(ir, opts);
|
|
991
|
+
const phrase = cadence ?
|
|
992
|
+
bareMinutes(ir, opts) + ', ' + cadence + trailingQualifier(ir, opts) :
|
|
993
|
+
// A minute list over purely enumerated hours (step fires, all singles) —
|
|
994
|
+
// hours-first, drop "joka tunti".
|
|
995
|
+
hoursFirstMinutes(hourSegmentTimes(ir, 0, null, opts), ir, opts) +
|
|
996
|
+
trailingQualifier(ir, opts);
|
|
837
997
|
|
|
838
998
|
return ir.analyses.clockSecond ?
|
|
839
999
|
secondsLeadClause(ir, opts) + ', ' + phrase :
|
|
@@ -864,8 +1024,82 @@ const renderers = {
|
|
|
864
1024
|
|
|
865
1025
|
// --- Step phrases. ---
|
|
866
1026
|
|
|
1027
|
+
// A step cadence to phrase over a `cycle`-long field (60 for minute/second),
|
|
1028
|
+
// running from `start` to `last`.
|
|
1029
|
+
interface Stride {
|
|
1030
|
+
interval: number;
|
|
1031
|
+
start: number;
|
|
1032
|
+
last: number;
|
|
1033
|
+
cycle: number;
|
|
1034
|
+
unit: UnitForms;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Speak a step cadence over a `cycle`-long field. A clean stride from the top
|
|
1038
|
+
// of the cycle is the bare cadence ("viiden minuutin välein"); a uniform
|
|
1039
|
+
// offset (start within the first interval, the interval still dividing the
|
|
1040
|
+
// cycle) names only its start, since it wraps cleanly with no distinct
|
|
1041
|
+
// endpoint ("kuuden minuutin välein jokaisen tunnin minuutista 5 alkaen"); a
|
|
1042
|
+
// non-uniform stride (start >= interval, or an interval that does not divide
|
|
1043
|
+
// the cycle) pins both endpoints so the bounded, non-wrapping set reads
|
|
1044
|
+
// unambiguously ("kahden minuutin välein minuutista 3 minuuttiin 59"). This is
|
|
1045
|
+
// the one phrasing for every step the renderer speaks, whether the core kept
|
|
1046
|
+
// it a step shape (a clean cadence) or enumerated it to a fire list (an
|
|
1047
|
+
// offset/uneven set the list path recognizes as a progression).
|
|
1048
|
+
function renderStride(stride: Stride, opts: NormalizedOptions): string {
|
|
1049
|
+
const {interval, start, last, cycle, unit} = stride;
|
|
1050
|
+
const cadence = genitive(interval, opts) + ' ' + unit.gen + ' välein';
|
|
1051
|
+
const tiles = cycle % interval === 0;
|
|
1052
|
+
|
|
1053
|
+
if (start === 0 && tiles) {
|
|
1054
|
+
return cadence;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (start < interval && tiles) {
|
|
1058
|
+
return cadence + ' ' + unit.anchor + ' ' + unit.ela + ' ' + start +
|
|
1059
|
+
' alkaen';
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
return cadence + ' ' + unit.ela + ' ' + start + ' ' + unit.ill + ' ' + last;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Speak a minute/second field's enumerated fires as a step cadence when they
|
|
1066
|
+
// form an arithmetic progression long enough to beat the list (the core
|
|
1067
|
+
// enumerates an offset/uneven step to this fire list; the IR is unchanged, so
|
|
1068
|
+
// the renderer recognizes the progression). Returns null for a non-progression
|
|
1069
|
+
// or a too-short list, leaving the caller to enumerate.
|
|
1070
|
+
function strideFromSegments(
|
|
1071
|
+
segments: Segment[],
|
|
1072
|
+
unit: UnitForms,
|
|
1073
|
+
opts: NormalizedOptions
|
|
1074
|
+
): string | null {
|
|
1075
|
+
const values = singleValues(segments);
|
|
1076
|
+
const step = values && arithmeticStep(values);
|
|
1077
|
+
|
|
1078
|
+
return step ?
|
|
1079
|
+
renderStride({...step, cycle: 60, unit}, opts) :
|
|
1080
|
+
null;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// The sorted numeric values a field's segments cover, or null if any segment
|
|
1084
|
+
// is not a discrete single (a range or sub-step is not a plain fire list).
|
|
1085
|
+
function singleValues(segments: Segment[]): number[] | null {
|
|
1086
|
+
const values: number[] = [];
|
|
1087
|
+
|
|
1088
|
+
for (const segment of segments) {
|
|
1089
|
+
if (segment.kind !== 'single') {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
values.push(+segment.value);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
return values;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
867
1099
|
// "viiden minuutin välein", "joka tunti 0 ja 31 minuutin kohdalla", or
|
|
868
|
-
// "kolmen minuutin välein jokaisen tunnin minuutista 1 alkaen".
|
|
1100
|
+
// "kolmen minuutin välein jokaisen tunnin minuutista 1 alkaen". A step shape
|
|
1101
|
+
// only reaches here as a clean or uniform-offset cadence; an offset/uneven set
|
|
1102
|
+
// arrives as a fire list and is recognized by the list path instead.
|
|
869
1103
|
function stepCycle60(
|
|
870
1104
|
segment: StepSegment,
|
|
871
1105
|
unit: UnitForms,
|
|
@@ -876,21 +1110,20 @@ function stepCycle60(
|
|
|
876
1110
|
}
|
|
877
1111
|
|
|
878
1112
|
const start = segment.startToken === '*' ? 0 : +segment.startToken;
|
|
879
|
-
const interval = segment.interval;
|
|
880
|
-
const cadence = genitive(interval, opts) + ' ' + unit.gen + ' välein';
|
|
881
1113
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
return cadence + ' ' + unit.anchor + ' ' + unit.ela + ' ' + start +
|
|
888
|
-
' alkaen';
|
|
1114
|
+
// A short offset cadence still lists its fires; the stride phrasing names
|
|
1115
|
+
// the interval and offset only once there are enough fires to beat the list.
|
|
1116
|
+
if (start !== 0 && segment.fires.length <= 3) {
|
|
1117
|
+
return atMarks(joinList(wordList(segment.fires)), unit, true);
|
|
889
1118
|
}
|
|
890
1119
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1120
|
+
return renderStride({
|
|
1121
|
+
interval: segment.interval,
|
|
1122
|
+
start,
|
|
1123
|
+
last: segment.fires[segment.fires.length - 1],
|
|
1124
|
+
cycle: 60,
|
|
1125
|
+
unit
|
|
1126
|
+
}, opts);
|
|
894
1127
|
}
|
|
895
1128
|
|
|
896
1129
|
// "kahden tunnin välein", "klo 0, 10 ja 20", or "viiden tunnin välein
|
|
@@ -917,6 +1150,285 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
|
|
|
917
1150
|
return cadence + ' klo ' + hourElatives[start] + ' alkaen';
|
|
918
1151
|
}
|
|
919
1152
|
|
|
1153
|
+
// --- Hour-step cadence (the 24-cycle analog of renderStride). ---
|
|
1154
|
+
|
|
1155
|
+
// Speak an hour stride as a cadence with clock-time bounds: a clean stride
|
|
1156
|
+
// from midnight is the bare cadence ("kahden tunnin välein"); a clean offset
|
|
1157
|
+
// names only its start ("kuuden tunnin välein klo 2:sta alkaen"); a bounded or
|
|
1158
|
+
// non-tiling stride pins both clock-time endpoints ("kahden tunnin välein klo
|
|
1159
|
+
// 9–17") so the bounded set reads unambiguously. Used wherever an hour step
|
|
1160
|
+
// (or arithmetic-progression hour list) would otherwise be cross-multiplied
|
|
1161
|
+
// into a wall of clock times.
|
|
1162
|
+
function hourStrideCadence(
|
|
1163
|
+
stride: {start: number; interval: number; last: number},
|
|
1164
|
+
opts: NormalizedOptions
|
|
1165
|
+
): string {
|
|
1166
|
+
const {start, interval, last} = stride;
|
|
1167
|
+
const cadence = genitive(interval, opts) + ' tunnin välein';
|
|
1168
|
+
const tiles = 24 % interval === 0;
|
|
1169
|
+
|
|
1170
|
+
if (start === 0 && tiles) {
|
|
1171
|
+
return cadence;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (start < interval && tiles) {
|
|
1175
|
+
return cadence + ' klo ' + hourElatives[start] + ' alkaen';
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
return cadence + ' ' +
|
|
1179
|
+
kloRange({hour: start, minute: 0}, {hour: last, minute: 0}, opts);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// An hour list's arithmetic progression, or null when its values are not a step
|
|
1183
|
+
// the renderer should speak as a cadence. The core rewrites a uneven hour step
|
|
1184
|
+
// (whose interval does not tile 24, e.g. `*/5` → 0,5,10,15,20) to its literal
|
|
1185
|
+
// fire list, indistinguishable in the IR from a hand-written list; the renderer
|
|
1186
|
+
// recovers the cadence from the values. A progression starting at zero is a
|
|
1187
|
+
// `*/n` step however short (0,7,14,21 is `*/7`); a non-zero progression is only
|
|
1188
|
+
// a step when it is too long to be a deliberate clock-time list (9,17 is two
|
|
1189
|
+
// named times, not a cadence). Interval one is a plain range, never a step.
|
|
1190
|
+
function hourListStride(
|
|
1191
|
+
values: number[]
|
|
1192
|
+
): {start: number; interval: number; last: number} | null {
|
|
1193
|
+
if (values.length < 2) {
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const interval = values[1] - values[0];
|
|
1198
|
+
|
|
1199
|
+
if (interval < 2) {
|
|
1200
|
+
return null;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
for (let i = 2; i < values.length; i += 1) {
|
|
1204
|
+
if (values[i] - values[i - 1] !== interval) {
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (values[0] !== 0 && values.length < 5) {
|
|
1210
|
+
return null;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
return {interval, last: values[values.length - 1], start: values[0]};
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Whether an hour stride wraps the day cleanly from within its first interval
|
|
1217
|
+
// (a `*/n` from the top, or a `m/n` offset with m < n that divides 24): such a
|
|
1218
|
+
// stride has no distinct endpoint and keeps its bare or "alkaen" cadence. Every
|
|
1219
|
+
// other stride — a uneven interval, or one starting at or past its interval (a
|
|
1220
|
+
// bounded `a-b/n`) — is a bounded set the cadence pins both endpoints of.
|
|
1221
|
+
function offsetCleanStride(
|
|
1222
|
+
stride: {start: number; interval: number}
|
|
1223
|
+
): boolean {
|
|
1224
|
+
return stride.start < stride.interval && 24 % stride.interval === 0;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// The hour field's stride, or null when the hour is not a cadence: a step
|
|
1228
|
+
// segment yields its {start, interval, last} directly; an all-single hour list
|
|
1229
|
+
// yields one only when its values form a step progression (so an irregular list
|
|
1230
|
+
// like 9,17 keeps enumerating). The IR is unchanged — the renderer recognizes
|
|
1231
|
+
// the stride and speaks it as a cadence, not the clock-time cross-product.
|
|
1232
|
+
function hourStride(
|
|
1233
|
+
ir: IR
|
|
1234
|
+
): {start: number; interval: number; last: number} | null {
|
|
1235
|
+
const segments = ir.analyses.segments.hour;
|
|
1236
|
+
|
|
1237
|
+
// A wildcard hour carries no segments (no discrete hours to stride over).
|
|
1238
|
+
if (!segments) {
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
if (segments.length === 1 && segments[0].kind === 'step') {
|
|
1243
|
+
const segment = segments[0];
|
|
1244
|
+
|
|
1245
|
+
// A bounded step that fires only once (e.g. `9-10/5` -> just 9) is a single
|
|
1246
|
+
// value, not a stride: it has no interval to speak and no endpoint to pin.
|
|
1247
|
+
if (segment.fires.length < 2) {
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const start = segment.startToken === '*' ?
|
|
1252
|
+
0 :
|
|
1253
|
+
+segment.startToken.split('-')[0];
|
|
1254
|
+
|
|
1255
|
+
return {interval: segment.interval, last: segment.fires[
|
|
1256
|
+
segment.fires.length - 1], start};
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const values = singleValues(segments);
|
|
1260
|
+
|
|
1261
|
+
return values && hourListStride(values);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// The bounded cadence for an hour stride that pins both clock-time endpoints,
|
|
1265
|
+
// or null when the hour is not such a stride. The core rewrites a uneven step
|
|
1266
|
+
// to its fire list, so a minute window/list/step crossed with it lands in the
|
|
1267
|
+
// enumerating list paths; there the bounded hour reads better as its cadence
|
|
1268
|
+
// ("…, viiden tunnin välein klo 0–20") than as a wall of clock times. An
|
|
1269
|
+
// offset-clean stride keeps its existing confinement form, so only the
|
|
1270
|
+
// endpoint-bearing case routes here.
|
|
1271
|
+
function unevenHourCadence(ir: IR, opts: NormalizedOptions): string | null {
|
|
1272
|
+
const stride = hourStride(ir);
|
|
1273
|
+
|
|
1274
|
+
if (!stride || offsetCleanStride(stride)) {
|
|
1275
|
+
return null;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
return hourStrideCadence(stride, opts);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// The second's status against a pinned minute: a wildcard or sub-minute step
|
|
1282
|
+
// fills the minute (a "minuutin ajan" frame at minute 0); a single 0 is just
|
|
1283
|
+
// the top of the minute (no clause); anything else needs its own clause.
|
|
1284
|
+
function subMinuteSecond(ir: IR): boolean {
|
|
1285
|
+
return ir.pattern.second === '*' || ir.shapes.second === 'step';
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// The lead clause for an hour-cadence rendering: the second and the pinned
|
|
1289
|
+
// minute, before the hour cadence. A pinned minute 0 folds in — a single,
|
|
1290
|
+
// list, range, or step second is counted at its own bare "kohdalla" mark (the
|
|
1291
|
+
// minute-0 is the top of the hour), and a wildcard second takes a "minuutin
|
|
1292
|
+
// ajan" frame (the whole minute-0 window). A non-zero minute is a real clock
|
|
1293
|
+
// minute: the second leads with its own clause (if any), then the minute reads
|
|
1294
|
+
// at its bare "kohdalla" mark.
|
|
1295
|
+
function hourCadenceLead(ir: IR, minute: number,
|
|
1296
|
+
opts: NormalizedOptions): string {
|
|
1297
|
+
if (minute === 0) {
|
|
1298
|
+
if (subMinuteSecond(ir)) {
|
|
1299
|
+
return secondsLeadClause(ir, opts) + ' minuutin ajan';
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return secondsLeadClause(ir, opts);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const minutePhrase = atMarks(String(minute), units.minute, false);
|
|
1306
|
+
|
|
1307
|
+
// A single 0 second is just the top of the minute, so the minute leads
|
|
1308
|
+
// alone; any other second prefixes its own clause.
|
|
1309
|
+
if (ir.pattern.second === '0') {
|
|
1310
|
+
return minutePhrase;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
return secondsLeadClause(ir, opts) + ', ' + minutePhrase;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Render an hour step (or arithmetic-progression hour list) under a single
|
|
1317
|
+
// pinned minute and a second as a cadence — the lead clause, then the hour
|
|
1318
|
+
// cadence — instead of cross-multiplying the hours into a wall of clock times.
|
|
1319
|
+
// Returns null when the hour is not a stride (an irregular list, a single
|
|
1320
|
+
// hour, or a range), or when the cross-product is short enough that
|
|
1321
|
+
// enumeration is no longer than the cadence: a meaningful second makes every
|
|
1322
|
+
// clock time three digit-groups, so any stride is worth compacting; otherwise
|
|
1323
|
+
// the stride must exceed the clock-time cap, the same point at which the core
|
|
1324
|
+
// itself stops enumerating. Renderer-only; the IR is unchanged.
|
|
1325
|
+
function hourCadence(ir: IR, minute: number,
|
|
1326
|
+
opts: NormalizedOptions): string | null {
|
|
1327
|
+
const stride = hourStride(ir);
|
|
1328
|
+
|
|
1329
|
+
if (!stride) {
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const fires = (stride.last - stride.start) / stride.interval + 1;
|
|
1334
|
+
|
|
1335
|
+
// A short stride that spells out as few clock times stays an enumeration only
|
|
1336
|
+
// when it wraps cleanly (an offset-clean stride with no endpoint): the bare
|
|
1337
|
+
// or "alkaen" form is no shorter than the list. A bounded or uneven stride
|
|
1338
|
+
// has no clean wrap, so its endpoint-pinning cadence ("viiden tunnin välein
|
|
1339
|
+
// klo 0–20") reads better however short.
|
|
1340
|
+
if (ir.pattern.second === '0' && fires <= maxClockTimes &&
|
|
1341
|
+
offsetCleanStride(stride)) {
|
|
1342
|
+
return null;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// A wildcard or sub-minute step second confined to minute 0 of a clean hour
|
|
1346
|
+
// stride is a confinement, not a juxtaposed cadence: it reads "minuutin ajan
|
|
1347
|
+
// joka toisen tunnin aikana", reusing the every-Nth-hour idiom so the
|
|
1348
|
+
// minute-0 window is never heard as the bare hour cadence.
|
|
1349
|
+
const segment = ir.analyses.segments.hour![0];
|
|
1350
|
+
const confined = minute === 0 && subMinuteSecond(ir) &&
|
|
1351
|
+
ir.analyses.segments.hour!.length === 1 && segment.kind === 'step' &&
|
|
1352
|
+
cleanHourStride(segment);
|
|
1353
|
+
|
|
1354
|
+
if (confined) {
|
|
1355
|
+
return secondsLeadClause(ir, opts) + ' minuutin ajan ' +
|
|
1356
|
+
everyNthHour(segment, opts) + trailingQualifier(ir, opts);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// A plain top-of-the-hour fire (minute 0 with no meaningful second) has no
|
|
1360
|
+
// lead clause to fold in, so the bounded cadence stands on its own ("viiden
|
|
1361
|
+
// tunnin välein klo 0–20").
|
|
1362
|
+
if (minute === 0 && ir.pattern.second === '0') {
|
|
1363
|
+
return hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
return hourCadenceLead(ir, minute, opts) + ', ' +
|
|
1367
|
+
hourStrideCadence(stride, opts) + trailingQualifier(ir, opts);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Whether an hour step is a clean stride over the whole day — unbounded,
|
|
1371
|
+
// dividing 24, and starting within the first interval — so it confines to "joka
|
|
1372
|
+
// N:nnen tunnin aikana" rather than enumerating its fires.
|
|
1373
|
+
function cleanHourStride(segment: StepSegment): boolean {
|
|
1374
|
+
if (segment.startToken.indexOf('-') !== -1) {
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const start = segment.startToken === '*' ? 0 : +segment.startToken;
|
|
1379
|
+
|
|
1380
|
+
return 24 % segment.interval === 0 && start < segment.interval;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Whether the hour field is a range — or a list whose segments include a
|
|
1384
|
+
// range — and so forms a window rather than a cross-product of clock times.
|
|
1385
|
+
// A pure single-value list (9,17) has no range to span and still enumerates;
|
|
1386
|
+
// a step is handled by hourStride/hourCadence.
|
|
1387
|
+
function hasHourWindow(ir: IR): boolean {
|
|
1388
|
+
const segments = ir.analyses.segments.hour;
|
|
1389
|
+
|
|
1390
|
+
return !!segments && segments.some(function range(segment: Segment) {
|
|
1391
|
+
return segment.kind === 'range';
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// The hour-range window as a cadence tail at the top of each hour: a lone
|
|
1396
|
+
// range is the bare "klo 9–17"; a range plus a non-contiguous hour joins it
|
|
1397
|
+
// with "sekä klo" ("klo 9–20 sekä klo 22"), the same idiom the bare folded
|
|
1398
|
+
// window uses. The minute has folded into the lead, so the window closes on
|
|
1399
|
+
// the top of its final hour.
|
|
1400
|
+
function hourRangeWindowTail(ir: IR, opts: NormalizedOptions): string {
|
|
1401
|
+
return ir.analyses.segments.hour!.length === 1 ?
|
|
1402
|
+
hourSegmentTimes(ir, 0, null, opts) :
|
|
1403
|
+
hourSegmentTimesWithSeka(ir, 0, null, opts);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Render an hour range (or a list whose segments include a range) under
|
|
1407
|
+
// minute 0 and a meaningful second as the hour-range window — the lead clause,
|
|
1408
|
+
// then "klo 9–17" — instead of cross-multiplying the hours into a wall of
|
|
1409
|
+
// clock times. The hour-RANGE analog of hourCadence. Returns null when the
|
|
1410
|
+
// hour has no range, when the minute is non-zero (a real clock minute the
|
|
1411
|
+
// existing window form already speaks), or when a plain :00 set carries no
|
|
1412
|
+
// clause. Renderer-only; the IR is unchanged.
|
|
1413
|
+
function hourRangeCadence(ir: IR, minute: number,
|
|
1414
|
+
opts: NormalizedOptions): string | null {
|
|
1415
|
+
if (minute !== 0 || !hasHourWindow(ir) || ir.pattern.second === '0') {
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
const tail = hourRangeWindowTail(ir, opts);
|
|
1420
|
+
|
|
1421
|
+
// A wildcard or sub-minute step second is the whole minute-0 window
|
|
1422
|
+
// ("minuutin ajan", carried by hourCadenceLead), then the window — kept
|
|
1423
|
+
// distinct from the bare "joka tunti klo 9–17" so the confinement is never
|
|
1424
|
+
// heard as it (the hour-range analog of "minuutin ajan joka toisen tunnin
|
|
1425
|
+
// aikana"). A meaningful second leads at its mark, then the window.
|
|
1426
|
+
const joiner = subMinuteSecond(ir) ? ' ' : ', ';
|
|
1427
|
+
|
|
1428
|
+
return hourCadenceLead(ir, minute, opts) + joiner + tail +
|
|
1429
|
+
trailingQualifier(ir, opts);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
920
1432
|
// --- Hour-time phrasing. ---
|
|
921
1433
|
|
|
922
1434
|
// On-the-hour fires as one klo phrase: "klo 0, 10 ja 20".
|
|
@@ -944,23 +1456,32 @@ function kloFromTimes(
|
|
|
944
1456
|
return hourSegmentTimes(ir, 0, null, opts);
|
|
945
1457
|
}
|
|
946
1458
|
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
1459
|
+
// The hours accompanying a named-once minute clause under an hour list or
|
|
1460
|
+
// step. On-the-hour hours (a fires set, or a segment set with no real range)
|
|
1461
|
+
// are listed once — "klo 0, 5, 10, 15 ja 20" — so the minute is never repeated
|
|
1462
|
+
// as a per-hour span. A real hour RANGE segment is a genuine span and keeps its
|
|
1463
|
+
// per-segment window ("klo 8.00–18.59 ja 22.00–22.59"), mirroring the other
|
|
1464
|
+
// languages, which list discrete hours but keep range windows.
|
|
950
1465
|
function hourWindowsFromTimes(
|
|
951
1466
|
ir: IR,
|
|
952
1467
|
times: HourTimesPlan,
|
|
953
1468
|
opts: NormalizedOptions
|
|
954
1469
|
): string {
|
|
955
1470
|
if (times.kind === 'fires') {
|
|
956
|
-
return
|
|
957
|
-
|
|
958
|
-
|
|
1471
|
+
return kloList(times.fires, opts);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const segments = ir.analyses.segments.hour!;
|
|
1475
|
+
|
|
1476
|
+
if (!segments.some(function ranged(segment: Segment) {
|
|
1477
|
+
return segment.kind === 'range';
|
|
1478
|
+
})) {
|
|
1479
|
+
return kloList(hourSegmentFires(segments), opts);
|
|
959
1480
|
}
|
|
960
1481
|
|
|
961
1482
|
const pieces: string[] = [];
|
|
962
1483
|
|
|
963
|
-
|
|
1484
|
+
segments.forEach(function window(segment: Segment) {
|
|
964
1485
|
if (segment.kind === 'range') {
|
|
965
1486
|
pieces.push(rangeDigits({hour: +segment.bounds[0], minute: 0},
|
|
966
1487
|
{hour: +segment.bounds[1], minute: 59}, opts));
|
|
@@ -978,6 +1499,23 @@ function hourWindowsFromTimes(
|
|
|
978
1499
|
return 'klo ' + joinList(pieces);
|
|
979
1500
|
}
|
|
980
1501
|
|
|
1502
|
+
// The on-the-hour fires of a range-free hour segment set, in order: a step
|
|
1503
|
+
// segment contributes its enumerated fires, a single its one value.
|
|
1504
|
+
function hourSegmentFires(segments: Segment[]): number[] {
|
|
1505
|
+
const hours: number[] = [];
|
|
1506
|
+
|
|
1507
|
+
segments.forEach(function each(segment: Segment) {
|
|
1508
|
+
if (segment.kind === 'step') {
|
|
1509
|
+
hours.push(...segment.fires);
|
|
1510
|
+
}
|
|
1511
|
+
else if (segment.kind === 'single') {
|
|
1512
|
+
hours.push(+segment.value);
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
return hours;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
981
1519
|
// "9.00–9.59": one hour as a dash window, in digits.
|
|
982
1520
|
function hourWindowDigits(hour: number, opts: NormalizedOptions): string {
|
|
983
1521
|
return rangeDigits({hour, minute: 0}, {hour, minute: 59}, opts);
|