cronli5 0.1.1 → 0.1.4

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.
@@ -10,6 +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
15
  import {resolveDialect} from './dialects.js';
14
16
  import type {
15
17
  ClockTime, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode,
@@ -62,7 +64,6 @@ interface UnitForms {
62
64
  anchor: string;
63
65
  ela: string;
64
66
  gen: string;
65
- restart: string;
66
67
  }
67
68
 
68
69
  // Genitive numerals for the "N <unit>in välein" construction, spelled
@@ -160,16 +161,6 @@ const monthStems: (string | null)[] = [
160
161
  'joulu'
161
162
  ];
162
163
 
163
- // Cron token vocabulary (JAN..DEC, SUN..SAT) is part of cron syntax; map
164
- // it to field numbers.
165
- const monthTokens: {[token: string]: number} = {
166
- JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
167
- JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12
168
- };
169
- const weekdayTokens: {[token: string]: number} = {
170
- SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6
171
- };
172
-
173
164
  // Unit form tables for the anchored-minute/second constructions.
174
165
  // `mark` is the frequency for the "N minuutin kohdalla" ("at the
175
166
  // N-minute mark") form; `anchor` is the possessive for the elative
@@ -179,15 +170,13 @@ const units: {minute: UnitForms; second: UnitForms} = {
179
170
  mark: 'joka tunti',
180
171
  anchor: 'jokaisen tunnin',
181
172
  ela: 'minuutista',
182
- gen: 'minuutin',
183
- restart: 'tasatunnista alkaen'
173
+ gen: 'minuutin'
184
174
  },
185
175
  second: {
186
176
  mark: 'joka minuutti',
187
177
  anchor: 'jokaisen minuutin',
188
178
  ela: 'sekunnista',
189
- gen: 'sekunnin',
190
- restart: 'joka minuutti'
179
+ gen: 'sekunnin'
191
180
  }
192
181
  };
193
182
 
@@ -352,9 +341,42 @@ function renderComposeSeconds(
352
341
  hourClause + trailingQualifier(ir, opts);
353
342
  }
354
343
 
344
+ // A sub-minute second with the minute pinned to 0 and a specific hour: the
345
+ // clock-time rest would read "klo 9", dropping the pinned :00 and so the
346
+ // one-minute confinement (60 fires in :00, not 3,600 across the hour). Bind
347
+ // the seconds to the explicit clock minute with the "minuutin HH.00 aikana"
348
+ // frame (an "of"/during form, never a range) and trail the day qualifier
349
+ // ("joka sekunti minuutin 9.00 aikana, joka päivä").
350
+ if (plan.rest.kind === 'clockTimes' &&
351
+ plan.rest.times.every((time) => +time.minute === 0)) {
352
+ return composeMinuteZero(ir, plan.rest, opts);
353
+ }
354
+
355
355
  return secondsLeadClause(ir, opts) + ', ' + render(ir, plan.rest, opts);
356
356
  }
357
357
 
358
+ // The minute-0 confinement: bind the seconds to the explicit clock minute(s)
359
+ // in the "minuutin/minuuttien HH.00 aikana" frame (an "of"/during form, never
360
+ // a range — a range would round-trip back to the whole hour) and trail the day
361
+ // qualifier ("joka sekunti minuutin 9.00 aikana, joka päivä").
362
+ function composeMinuteZero(
363
+ ir: IR,
364
+ rest: Extract<PlanNode, {kind: 'clockTimes'}>,
365
+ opts: NormalizedOptions
366
+ ): string {
367
+ const clocks = rest.times.map(function clock(time): string {
368
+ return clockDigits({hour: time.hour, minute: time.minute},
369
+ {sep: opts.style.sep});
370
+ });
371
+ const frame = clocks.length === 1 ?
372
+ 'minuutin ' + clocks[0] :
373
+ 'minuuttien ' + joinList(clocks);
374
+ const dayTrail = leadingQualifier(ir, opts).trimEnd();
375
+
376
+ return secondsLeadClause(ir, opts) + ' ' + frame + ' aikana' +
377
+ (dayTrail ? ', ' + dayTrail : '');
378
+ }
379
+
358
380
  // The leading clause describing a second field relative to the minute.
359
381
  function secondsLeadClause(ir: IR, opts: NormalizedOptions): string {
360
382
  const secondField = ir.pattern.second;
@@ -545,12 +567,20 @@ function renderMinuteFrequency(
545
567
  return phrase + trailingQualifier(ir, opts);
546
568
  }
547
569
 
548
- // "joka minuutti klo 9.00–9.59".
570
+ // "joka minuutti klo 9.00–9.59". A wildcard minute is the whole hour, so it
571
+ // reads as that hour itself ("joka minuutti kello 9 aikana") rather than a
572
+ // synthesized "klo 9.00–9.59" range the source never stated; a plain range is
573
+ // a real window and keeps the dash form.
549
574
  function renderMinuteSpanInHour(
550
575
  ir: IR,
551
576
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
552
577
  opts: NormalizedOptions
553
578
  ): string {
579
+ if (ir.pattern.minute === '*') {
580
+ return 'joka minuutti kello ' + plan.hour + ' aikana' +
581
+ trailingQualifier(ir, opts);
582
+ }
583
+
554
584
  return 'joka minuutti ' +
555
585
  kloRange({hour: plan.hour, minute: plan.span[0]},
556
586
  {hour: plan.hour, minute: plan.span[1]}, opts) +
@@ -858,15 +888,9 @@ function stepCycle60(
858
888
  ' alkaen';
859
889
  }
860
890
 
861
- if (60 % interval === 0) {
862
- return cadence;
863
- }
864
-
865
- if (segment.fires.length <= 2) {
866
- return atMarks(joinList(wordList(segment.fires)), unit, true);
867
- }
868
-
869
- return cadence + ' ' + unit.restart;
891
+ // A clean stride from the top of the cycle is the bare cadence. (An uneven
892
+ // stride is rewritten to its fires upstream and never reaches here.)
893
+ return cadence;
870
894
  }
871
895
 
872
896
  // "kahden tunnin välein", "klo 0, 10 ja 20", or "viiden tunnin välein
@@ -880,7 +904,9 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
880
904
  const interval = segment.interval;
881
905
  const cadence = genitive(interval, opts) + ' tunnin välein';
882
906
 
883
- if (start === 0 && 24 % interval === 0) {
907
+ // A clean stride from midnight is the bare cadence. (An uneven stride is
908
+ // rewritten to its fires upstream and never reaches here.)
909
+ if (start === 0) {
884
910
  return cadence;
885
911
  }
886
912
 
@@ -888,10 +914,6 @@ function stepHours(segment: StepSegment, opts: NormalizedOptions): string {
888
914
  return kloList(segment.fires, opts);
889
915
  }
890
916
 
891
- if (start === 0) {
892
- return cadence + ' keskiyöstä alkaen';
893
- }
894
-
895
917
  return cadence + ' klo ' + hourElatives[start] + ' alkaen';
896
918
  }
897
919
 
@@ -1350,18 +1372,16 @@ function quartzWeekdayPhrase(weekdayField: string): string | undefined {
1350
1372
  }
1351
1373
  }
1352
1374
 
1353
- // Resolve a weekday token or number to its table index.
1375
+ // Resolve a weekday to its table index. Weekday-field segments are already
1376
+ // canonical numbers; a Quartz stem (`5L`, `MON#2`) is not, so resolve any
1377
+ // name via the core's index (with the Sunday alias 7 folding to 0).
1354
1378
  function weekdayNumber(token: string | number): number {
1355
- if (token in weekdayTokens) {
1356
- return weekdayTokens[token];
1357
- }
1358
-
1359
- return +token % 7;
1379
+ return toFieldNumber('' + token, weekdayNumbers) % 7;
1360
1380
  }
1361
1381
 
1362
- // Resolve a month token or number to its table index.
1382
+ // Resolve a canonical month number to its table index.
1363
1383
  function monthNumber(token: string | number): number {
1364
- return monthTokens[token] || +token;
1384
+ return +token;
1365
1385
  }
1366
1386
 
1367
1387
  // --- Years. ---
@@ -1503,7 +1523,10 @@ const fi: Language = {
1503
1523
  fallback: 'tunnistamaton cron-lauseke',
1504
1524
  options: normalizeOptions,
1505
1525
  reboot: 'järjestelmän käynnistyessä',
1506
- sentence: (description) => 'Suoritetaan ' + description + '.'
1526
+ // A description ending in a period already carries it, so closing the
1527
+ // sentence must not double it.
1528
+ sentence: (description) =>
1529
+ 'Suoritetaan ' + description + (description.endsWith('.') ? '' : '.')
1507
1530
  };
1508
1531
 
1509
1532
  export default fi;
@@ -217,11 +217,22 @@ function hourFrame(ir: IR): string {
217
217
 
218
218
  // A repeating minute step, optionally confined to active hours.
219
219
  function renderMinuteFrequency(ir: IR, plan: PlanNode): string {
220
- const base = cadence(stepSegment(ir, 'minute').interval, UNITS.minute);
220
+ const minuteStep = stepSegment(ir, 'minute');
221
+ // A "每N分钟" cadence is only faithful from the top of the hour; an offset
222
+ // step (5/6 fires at :05,:11,…) enumerates its fires instead.
223
+ const base = minuteStep.startToken === '*' ?
224
+ cadence(minuteStep.interval, UNITS.minute) :
225
+ renderMinutePast(ir);
221
226
  const {hours} = plan as Extract<PlanNode, {kind: 'minuteFrequency'}>;
222
227
 
223
228
  if (hours.kind === 'step') {
224
- return cadence(stepSegment(ir, 'hour').interval, UNITS.hour) + base;
229
+ const hourStep = stepSegment(ir, 'hour');
230
+
231
+ // "每N小时" is only faithful from midnight; an offset step (2/6 fires at
232
+ // 2,8,14,20) enumerates its hours instead.
233
+ return hourStep.startToken === '*' ?
234
+ cadence(hourStep.interval, UNITS.hour) + base :
235
+ '在' + hourList(ir) + ',' + base;
225
236
  }
226
237
 
227
238
  if (hours.kind === 'single' ||
@@ -242,10 +253,16 @@ function renderMinuteFrequency(ir: IR, plan: PlanNode): string {
242
253
  return base;
243
254
  }
244
255
 
245
- // A minute span within a single hour: "在9点至9点58分之间,每分钟".
256
+ // A minute span within a single hour. A wildcard minute reads as that hour
257
+ // itself — "凌晨0点的每一分钟" — not a synthesized "在H点至H点59分之间" range the
258
+ // source never stated; a partial minute span keeps the named range.
246
259
  function renderMinuteSpanInHour(ir: IR, plan: PlanNode): string {
247
260
  const span = plan as Extract<PlanNode, {kind: 'minuteSpanInHour'}>;
248
261
 
262
+ if (ir.pattern.minute === '*') {
263
+ return hourWord(span.hour) + '的每一分钟';
264
+ }
265
+
249
266
  return '在' + hourWord(span.hour) + '至' + span.hour + '点' +
250
267
  span.span[1] + '分之间,每分钟';
251
268
  }
@@ -266,15 +283,31 @@ function renderMinutesAcrossHours(ir: IR, plan: PlanNode): string {
266
283
  // A minute clause across a stepped hour field. A wildcard minute reads "每2小时
267
284
  // 内,每分钟"; a ranged minute names it: "每2小时,每小时0至30分,每分钟".
268
285
  function renderMinuteSpanAcrossHourStep(ir: IR, plan: PlanNode): string {
269
- const cad = cadence(stepSegment(ir, 'hour').interval, UNITS.hour);
286
+ const hourStep = stepSegment(ir, 'hour');
270
287
  const {form} = plan as Extract<PlanNode, {kind: 'minuteSpanAcrossHourStep'}>;
288
+ const minuteTail = form === 'wildcard' ?
289
+ '每分钟' :
290
+ '每小时' + valueList(fieldSegments(ir, 'minute'), '分') + ',每分钟';
271
291
 
272
- if (form === 'wildcard') {
273
- return cad + '内,每分钟';
292
+ // An offset stride (2/6 fires at 2,8,14,20) enumerates its hours like a
293
+ // discrete list; "每N小时" is faithful only from midnight.
294
+ if (hourStep.startToken !== '*') {
295
+ return form === 'wildcard' ?
296
+ '在' + hourList(ir) + ',' + minuteTail :
297
+ hourList(ir) + ',' + minuteTail;
298
+ }
299
+
300
+ // A step-2 hour from midnight IS exactly the even hours; name them so, rather
301
+ // than the vague "每2小时内" that reads as an interval. Other strides keep it.
302
+ if (hourStep.interval === 2 && form === 'wildcard') {
303
+ return '在偶数小时,' + minuteTail;
274
304
  }
275
305
 
276
- return cad + ',每小时' + valueList(fieldSegments(ir, 'minute'), '分') +
277
- ',每分钟';
306
+ const cad = cadence(hourStep.interval, UNITS.hour);
307
+
308
+ return form === 'wildcard' ?
309
+ cad + '内,' + minuteTail :
310
+ cad + ',' + minuteTail;
278
311
  }
279
312
 
280
313
  // Discrete clock times: "9点", "9点和17点".
@@ -323,8 +356,9 @@ function renderHourRange(ir: IR, plan: PlanNode): string {
323
356
  range.last + '分之间,每分钟';
324
357
  }
325
358
 
326
- // A stepped hour field: "每2小时", or "从凌晨0点起,每5小时" when the step does not
327
- // divide 24 (the cadence wraps), or its discrete fires as clock words.
359
+ // A stepped hour field: "每2小时", or its two fires as clock words when the
360
+ // stride fires only twice. An uneven stride (one that does not divide 24) is
361
+ // rewritten to its fire list upstream and never reaches here.
328
362
  function renderHourStep(ir: IR): string {
329
363
  const segment = stepSegment(ir, 'hour');
330
364
 
@@ -332,16 +366,11 @@ function renderHourStep(ir: IR): string {
332
366
  return hourList(ir);
333
367
  }
334
368
 
335
- // A step that fires only twice reads as two clock times ("凌晨0点和13点").
369
+ // A step that fires only twice reads as two clock times ("凌晨0点和正午").
336
370
  if (segment.fires.length <= 2) {
337
371
  return joinAnd(segment.fires.map(hourWord));
338
372
  }
339
373
 
340
- if (24 % segment.interval !== 0) {
341
- return '从' + hourWord(segment.fires[0]) + '起,' +
342
- cadence(segment.interval, UNITS.hour);
343
- }
344
-
345
374
  return cadence(segment.interval, UNITS.hour);
346
375
  }
347
376
 
@@ -421,11 +450,30 @@ function isHourCadence(ir: IR): boolean {
421
450
  function composeSecondsOnHour(ir: IR, plan: PlanNode, opts: Opts): string {
422
451
  const sec = secondClause(ir);
423
452
  const {rest} = plan as Extract<PlanNode, {kind: 'composeSeconds'}>;
424
- const restText = render(ir, rest, opts);
425
453
 
454
+ // The minute is pinned to 0 under a specific hour: a bare clock word ("9点")
455
+ // would hide the :00 and leave the second dangling ("…9点每秒"), reading as
456
+ // the whole hour. Fuse the seconds with the explicit clock minute ("9点0分
457
+ // 的每一秒"), so the one-minute confinement (60 fires in :00, not 3,600
458
+ // across the hour) stays visible. The daily frame leads with 每天; a weekday
459
+ // or date qualifier is added by describe().
426
460
  if ((rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') &&
427
- isDaily(ir)) {
428
- return '每天' + restText + sec;
461
+ ir.pattern.minute === '0') {
462
+ const clocks = hourFires(ir).map(function clock(hour): string {
463
+ return hourWord(hour) + '0分';
464
+ });
465
+ const tail = sec === '每秒' ? '的每一秒' : '的' + sec;
466
+ const core = joinAnd(clocks) + tail;
467
+
468
+ return isDaily(ir) ? '每天' + core : core;
469
+ }
470
+
471
+ const restText = render(ir, rest, opts);
472
+
473
+ if (rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes') {
474
+ if (isDaily(ir)) {
475
+ return '每天' + restText + sec;
476
+ }
429
477
  }
430
478
 
431
479
  // A stated minute (e.g. minute 0 under a sub-minute second) takes the same
@@ -465,6 +513,14 @@ function composeSecondsListed(ir: IR): string {
465
513
  const sec = secondClause(ir);
466
514
  const minutes = '每小时' + valueList(fieldSegments(ir, 'minute'), '分');
467
515
 
516
+ // A single restricted hour with an every-second cadence fuses the clock time
517
+ // with its minutes — "凌晨0点5、20、35、50分的每一秒" — rather than the "每小时"
518
+ // that falsely implies every hour. A non-wildcard second keeps the list form.
519
+ if (ir.shapes.hour === 'single' && sec === '每秒') {
520
+ return hourWord(hourFires(ir)[0]) +
521
+ valueList(fieldSegments(ir, 'minute'), '分') + '的每一秒';
522
+ }
523
+
468
524
  if (ir.shapes.hour === 'wildcard') {
469
525
  return minutes + ',' + sec;
470
526
  }
@@ -478,12 +534,28 @@ function composeSecondsListed(ir: IR): string {
478
534
  }
479
535
 
480
536
  // Seconds composed with the minute/hour structure, dispatched on the minute.
537
+ // A single minute over a composed clock-time rest (the core already joined the
538
+ // lone hour and minute into "N点M分") keeps that composition, attaching the
539
+ // second to it rather than splitting the minute back out into the "每小时N分"
540
+ // list path; a minute list stays on that list path so each fire is named.
481
541
  function renderComposeSeconds(ir: IR, plan: PlanNode, opts: Opts): string {
482
- if (ir.pattern.minute === '0') {
542
+ const {rest} = plan as Extract<PlanNode, {kind: 'composeSeconds'}>;
543
+ const composedClock =
544
+ rest.kind === 'clockTimes' || rest.kind === 'compactClockTimes';
545
+
546
+ if (ir.pattern.minute === '0' ||
547
+ composedClock && ir.shapes.minute === 'single') {
483
548
  return composeSecondsOnHour(ir, plan, opts);
484
549
  }
485
550
 
486
- if (ir.pattern.minute === '*' || ir.shapes.minute === 'step') {
551
+ // "每N分钟" is faithful only for a wildcard or top-of-hour step; an offset
552
+ // step (5/15 fires at :05,:20,…) takes the enumerated list path so its start
553
+ // is named, never dropped.
554
+ const minuteCadence = ir.pattern.minute === '*' ||
555
+ ir.shapes.minute === 'step' &&
556
+ stepSegment(ir, 'minute').startToken === '*';
557
+
558
+ if (minuteCadence) {
487
559
  return composeSecondsCadence(ir);
488
560
  }
489
561