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.
package/dist/lang/zh.js CHANGED
@@ -171,10 +171,12 @@ function hourFrame(ir) {
171
171
  return "\u5728" + hourList(ir) + "\uFF0C";
172
172
  }
173
173
  function renderMinuteFrequency(ir, plan) {
174
- const base = cadence(stepSegment(ir, "minute").interval, UNITS.minute);
174
+ const minuteStep = stepSegment(ir, "minute");
175
+ const base = minuteStep.startToken === "*" ? cadence(minuteStep.interval, UNITS.minute) : renderMinutePast(ir);
175
176
  const { hours } = plan;
176
177
  if (hours.kind === "step") {
177
- return cadence(stepSegment(ir, "hour").interval, UNITS.hour) + base;
178
+ const hourStep = stepSegment(ir, "hour");
179
+ return hourStep.startToken === "*" ? cadence(hourStep.interval, UNITS.hour) + base : "\u5728" + hourList(ir) + "\uFF0C" + base;
178
180
  }
179
181
  if (hours.kind === "single" || hours.kind === "window" && hours.from === hours.to) {
180
182
  return "\u5728" + hourWord(hours.from) + "\u81F3" + hours.from + "\u70B9" + hours.last + "\u5206\u4E4B\u95F4\uFF0C" + base;
@@ -189,6 +191,9 @@ function renderMinuteFrequency(ir, plan) {
189
191
  }
190
192
  function renderMinuteSpanInHour(ir, plan) {
191
193
  const span = plan;
194
+ if (ir.pattern.minute === "*") {
195
+ return hourWord(span.hour) + "\u7684\u6BCF\u4E00\u5206\u949F";
196
+ }
192
197
  return "\u5728" + hourWord(span.hour) + "\u81F3" + span.hour + "\u70B9" + span.span[1] + "\u5206\u4E4B\u95F4\uFF0C\u6BCF\u5206\u949F";
193
198
  }
194
199
  function renderMinutesAcrossHours(ir, plan) {
@@ -199,12 +204,17 @@ function renderMinutesAcrossHours(ir, plan) {
199
204
  return hourList(ir) + "\uFF0C\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
200
205
  }
201
206
  function renderMinuteSpanAcrossHourStep(ir, plan) {
202
- const cad = cadence(stepSegment(ir, "hour").interval, UNITS.hour);
207
+ const hourStep = stepSegment(ir, "hour");
203
208
  const { form } = plan;
204
- if (form === "wildcard") {
205
- return cad + "\u5185\uFF0C\u6BCF\u5206\u949F";
209
+ const minuteTail = form === "wildcard" ? "\u6BCF\u5206\u949F" : "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
210
+ if (hourStep.startToken !== "*") {
211
+ return form === "wildcard" ? "\u5728" + hourList(ir) + "\uFF0C" + minuteTail : hourList(ir) + "\uFF0C" + minuteTail;
206
212
  }
207
- return cad + "\uFF0C\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206") + "\uFF0C\u6BCF\u5206\u949F";
213
+ if (hourStep.interval === 2 && form === "wildcard") {
214
+ return "\u5728\u5076\u6570\u5C0F\u65F6\uFF0C" + minuteTail;
215
+ }
216
+ const cad = cadence(hourStep.interval, UNITS.hour);
217
+ return form === "wildcard" ? cad + "\u5185\uFF0C" + minuteTail : cad + "\uFF0C" + minuteTail;
208
218
  }
209
219
  function renderClockTimes(ir, plan, opts) {
210
220
  const { times } = plan;
@@ -239,9 +249,6 @@ function renderHourStep(ir) {
239
249
  if (segment.fires.length <= 2) {
240
250
  return joinAnd(segment.fires.map(hourWord));
241
251
  }
242
- if (24 % segment.interval !== 0) {
243
- return "\u4ECE" + hourWord(segment.fires[0]) + "\u8D77\uFF0C" + cadence(segment.interval, UNITS.hour);
244
- }
245
252
  return cadence(segment.interval, UNITS.hour);
246
253
  }
247
254
  function renderRangeOfMinutes(ir) {
@@ -293,9 +300,19 @@ function isHourCadence(ir) {
293
300
  function composeSecondsOnHour(ir, plan, opts) {
294
301
  const sec = secondClause(ir);
295
302
  const { rest } = plan;
303
+ if ((rest.kind === "clockTimes" || rest.kind === "compactClockTimes") && ir.pattern.minute === "0") {
304
+ const clocks = hourFires(ir).map(function clock(hour) {
305
+ return hourWord(hour) + "0\u5206";
306
+ });
307
+ const tail = sec === "\u6BCF\u79D2" ? "\u7684\u6BCF\u4E00\u79D2" : "\u7684" + sec;
308
+ const core = joinAnd(clocks) + tail;
309
+ return isDaily(ir) ? "\u6BCF\u5929" + core : core;
310
+ }
296
311
  const restText = render(ir, rest, opts);
297
- if ((rest.kind === "clockTimes" || rest.kind === "compactClockTimes") && isDaily(ir)) {
298
- return "\u6BCF\u5929" + restText + sec;
312
+ if (rest.kind === "clockTimes" || rest.kind === "compactClockTimes") {
313
+ if (isDaily(ir)) {
314
+ return "\u6BCF\u5929" + restText + sec;
315
+ }
299
316
  }
300
317
  if (rest.kind === "singleMinute") {
301
318
  return restText + "\uFF0C" + sec;
@@ -319,6 +336,9 @@ function composeSecondsCadence(ir) {
319
336
  function composeSecondsListed(ir) {
320
337
  const sec = secondClause(ir);
321
338
  const minutes = "\u6BCF\u5C0F\u65F6" + valueList(fieldSegments(ir, "minute"), "\u5206");
339
+ if (ir.shapes.hour === "single" && sec === "\u6BCF\u79D2") {
340
+ return hourWord(hourFires(ir)[0]) + valueList(fieldSegments(ir, "minute"), "\u5206") + "\u7684\u6BCF\u4E00\u79D2";
341
+ }
322
342
  if (ir.shapes.hour === "wildcard") {
323
343
  return minutes + "\uFF0C" + sec;
324
344
  }
@@ -328,10 +348,13 @@ function composeSecondsListed(ir) {
328
348
  return hourFrame(ir) + minutes + "\uFF0C" + sec;
329
349
  }
330
350
  function renderComposeSeconds(ir, plan, opts) {
331
- if (ir.pattern.minute === "0") {
351
+ const { rest } = plan;
352
+ const composedClock = rest.kind === "clockTimes" || rest.kind === "compactClockTimes";
353
+ if (ir.pattern.minute === "0" || composedClock && ir.shapes.minute === "single") {
332
354
  return composeSecondsOnHour(ir, plan, opts);
333
355
  }
334
- if (ir.pattern.minute === "*" || ir.shapes.minute === "step") {
356
+ const minuteCadence = ir.pattern.minute === "*" || ir.shapes.minute === "step" && stepSegment(ir, "minute").startToken === "*";
357
+ if (minuteCadence) {
335
358
  return composeSecondsCadence(ir);
336
359
  }
337
360
  return composeSecondsListed(ir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronli5",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Cron Like I'm Five: A Cron to English Utility",
5
5
  "repository": {
6
6
  "type": "git",
@@ -62,6 +62,7 @@
62
62
  "docs": "node --import tsx scripts/docs.mjs",
63
63
  "fuzz": "node --import tsx scripts/fuzz-lang.mjs",
64
64
  "lint": "eslint src test cli.js eslint.config.js scripts tooling/scripts",
65
+ "metamorphic": "node --import tsx tooling/scripts/metamorphic.mjs",
65
66
  "lint:fix": "eslint src test cli.js eslint.config.js scripts tooling/scripts --fix",
66
67
  "test": "mocha",
67
68
  "types": "tsc -p tsconfig.types.json",
@@ -4,10 +4,20 @@
4
4
 
5
5
  import {fieldOrder, fieldSpecs} from './specs.js';
6
6
  import type {CronLike, FieldSpec} from './specs.js';
7
- import type {Pattern} from './ir.js';
7
+ import type {Field, Pattern} from './ir.js';
8
8
  import {includes, toFieldNumber, unique} from './util.js';
9
9
  import {isQuartzDate, isQuartzWeekday} from './validate.js';
10
10
 
11
+ // The fixed-cycle time fields: their step intervals are measured against a
12
+ // closed cycle (60 seconds, 60 minutes, 24 hours), so a step is a true
13
+ // "every N" cadence only when it tiles that cycle. The calendar fields
14
+ // (date/month/weekday) have variable cycles and keep their step form.
15
+ const timeFieldCycle: Partial<Record<Field, number>> = {
16
+ hour: 24,
17
+ minute: 60,
18
+ second: 60
19
+ };
20
+
11
21
  // Quartz aliases: `?` reads "no specific value" (equivalent to `*`) in the
12
22
  // date and weekday fields, and a bare `L` weekday means Saturday.
13
23
  function applyQuartzAliases(cronPattern: CronLike): void {
@@ -40,7 +50,7 @@ function normalizeCronPattern(cronPattern: CronLike): Pattern {
40
50
  return;
41
51
  }
42
52
 
43
- cronPattern[field] = normalizeField(value, fieldSpecs[field]);
53
+ cronPattern[field] = normalizeField(value, field, fieldSpecs[field]);
44
54
  });
45
55
 
46
56
  // Every field is now a canonical string.
@@ -48,17 +58,23 @@ function normalizeCronPattern(cronPattern: CronLike): Pattern {
48
58
  }
49
59
 
50
60
  // Canonicalize a single validated field value to a string.
51
- function normalizeField(value: string, spec: FieldSpec): string {
61
+ function normalizeField(value: string, field: Field, spec: FieldSpec): string {
52
62
  const stringValue = '' + value;
53
63
 
54
64
  if (stringValue === '*') {
55
65
  return stringValue;
56
66
  }
57
67
 
68
+ const cycle = timeFieldCycle[field];
58
69
  const segments = stringValue.split(',').map(function canonical(segment) {
59
- return collapseDegenerateRange(
60
- collapseOnceStep(collapseUnitStep(segment, spec), spec), spec);
61
- });
70
+ return canonicalizeTokens(collapseFullSpanRange(
71
+ enumerateNonUniformStep(
72
+ collapseFullSpanStep(
73
+ collapseDegenerateRange(
74
+ collapseOnceStep(collapseUnitStep(segment, spec), spec), spec),
75
+ spec),
76
+ spec, cycle), spec), spec);
77
+ }).join(',').split(',');
62
78
 
63
79
  // A full-cycle segment covers the whole field.
64
80
  if (segments.indexOf('*') !== -1) {
@@ -70,6 +86,40 @@ function normalizeField(value: string, spec: FieldSpec): string {
70
86
  }).join(',');
71
87
  }
72
88
 
89
+ // Rewrite a segment's value tokens to their canonical numbers: a name
90
+ // (`MON`, `JAN`) becomes its index, and a weekday `7` (Sunday again, above
91
+ // `top`) folds to the field minimum (`0`). Applied to every token position —
92
+ // a single, both range bounds, and a step's start — so a normalized field
93
+ // never carries a surface name or the 7-for-Sunday alias. `*` and Quartz
94
+ // tokens (resolved earlier) are left untouched. Output is unchanged: a
95
+ // renderer maps the number back to its own name.
96
+ function canonicalizeTokens(segment: string, spec: FieldSpec): string {
97
+ // Only the named fields (month, weekday) carry name tokens or the
98
+ // weekday `7`-for-Sunday alias; every other field is already numeric.
99
+ if (!spec.numbers) {
100
+ return segment;
101
+ }
102
+
103
+ const parts = segment.split('/');
104
+ const start = parts[0].split('-').map(function fold(token) {
105
+ return canonicalizeToken(token, spec);
106
+ }).join('-');
107
+
108
+ return parts.length === 2 ? start + '/' + parts[1] : start;
109
+ }
110
+
111
+ // A single token to its canonical number string (`*` passes through).
112
+ function canonicalizeToken(token: string, spec: FieldSpec): string {
113
+ if (token === '*') {
114
+ return token;
115
+ }
116
+
117
+ const number = toFieldNumber(token, spec.numbers);
118
+
119
+ // A value above `top` (weekday 7) is the field minimum again.
120
+ return '' + (number > (spec.top as number) ? spec.min : number);
121
+ }
122
+
73
123
  // An interval-one step enumerates every value from its start, so it reads
74
124
  // as the equivalent range: `1/1` is `1-59` and `5-30/1` is `5-30`. A start
75
125
  // at the bottom of the cycle covers the whole field (`0/1` is `*`).
@@ -115,6 +165,94 @@ function collapseOnceStep(segment: string, spec: FieldSpec): string {
115
165
  return start === '*' ? '' + spec.min : start;
116
166
  }
117
167
 
168
+ // An unbounded step in a fixed-cycle time field is a true "every N" cadence
169
+ // only when it tiles the cycle: the interval divides it evenly and the start
170
+ // falls within the first interval (`*/15`, `5/6`). A step that fails either
171
+ // test fires at irregular points within the cycle, so it reads as the literal
172
+ // list of those fires (`*/7` is `0,7,14,…`), the same as if it were written
173
+ // out. Calendar fields (no `cycle`), bounded steps (`9-17/2`, a per-window
174
+ // stride), and non-step segments are left untouched.
175
+ function enumerateNonUniformStep(
176
+ segment: string,
177
+ spec: FieldSpec,
178
+ cycle: number | undefined
179
+ ): string {
180
+ const parts = segment.split('/');
181
+
182
+ if (typeof cycle !== 'number' || parts.length !== 2 ||
183
+ includes(parts[0], '-')) {
184
+ return segment;
185
+ }
186
+
187
+ const interval = +parts[1];
188
+ const start = parts[0] === '*' ? spec.min : toFieldNumber(parts[0]);
189
+
190
+ if (cycle % interval === 0 && start < interval) {
191
+ return segment;
192
+ }
193
+
194
+ const fires = [];
195
+
196
+ for (let value = start; value <= (spec.top as number); value += interval) {
197
+ fires.push(value);
198
+ }
199
+
200
+ return fires.join(',');
201
+ }
202
+
203
+ // A step whose range part covers the whole field (`0-59/2`, `0-23/2`) strides
204
+ // across the entire cycle, so the bound adds nothing — it reads as the
205
+ // unbounded `*/N` step (which the cycle test then renders as "every N" or its
206
+ // fires). Partial-range steps (`9-17/2`) keep their window.
207
+ function collapseFullSpanStep(segment: string, spec: FieldSpec): string {
208
+ const parts = segment.split('/');
209
+
210
+ if (parts.length !== 2 || !includes(parts[0], '-')) {
211
+ return segment;
212
+ }
213
+
214
+ return collapseFullSpanRange(parts[0], spec) === '*' ?
215
+ '*/' + parts[1] :
216
+ segment;
217
+ }
218
+
219
+ // A plain range whose enumerated values cover the whole field imposes no
220
+ // restriction, so it reads identically to `*` (`0-59` minute, `0-23` hour,
221
+ // `1-31` date, `1-12` month, and every seven-day weekday range — `0-6`,
222
+ // `1-7`, `0-7`, `SUN-SAT` — since cron's 7 is Sunday again, folding to the
223
+ // field minimum). Only bare ranges qualify: a step (`0-59/2`) keeps its
224
+ // cadence, so segments carrying a `/` are left untouched.
225
+ function collapseFullSpanRange(segment: string, spec: FieldSpec): string {
226
+ if (typeof spec.top !== 'number' || includes(segment, '/') ||
227
+ !includes(segment, '-')) {
228
+ return segment;
229
+ }
230
+
231
+ const bounds = segment.split('-');
232
+ const low = toFieldNumber(bounds[0], spec.numbers);
233
+ const high = toFieldNumber(bounds[1], spec.numbers);
234
+
235
+ if (low > high) {
236
+ return segment;
237
+ }
238
+
239
+ // The full field is min..top; a value above top (weekday 7) folds to min.
240
+ const top = spec.top as number;
241
+ const fired: Record<number, boolean> = {};
242
+
243
+ for (let value = low; value <= high; value += 1) {
244
+ fired[value > top ? spec.min : value] = true;
245
+ }
246
+
247
+ for (let value = spec.min; value <= top; value += 1) {
248
+ if (!fired[value]) {
249
+ return segment;
250
+ }
251
+ }
252
+
253
+ return '*';
254
+ }
255
+
118
256
  // A degenerate range (`9-9`) fires once, so it reads as its single value.
119
257
  // A stepped degenerate range (`9-9/5`) likewise fires only at its start.
120
258
  function collapseDegenerateRange(segment: string, spec: FieldSpec): string {
@@ -2,6 +2,8 @@
2
2
  // German. Anchored to Duden; see notes.md for the decisions.
3
3
 
4
4
  import {pad} from '../../core/format.js';
5
+ import {weekdayNumbers} from '../../core/specs.js';
6
+ import {toFieldNumber} from '../../core/util.js';
5
7
  import type {Cronli5Options} from '../../types.js';
6
8
  import type {
7
9
  Field, HourTimesPlan, IR, Language, NormalizedOptions, PlanNode, Segment
@@ -61,11 +63,6 @@ const weekdayNames = [
61
63
  'freitags', 'samstags'
62
64
  ];
63
65
 
64
- // Cron weekday tokens (part of cron syntax), mapped to indices.
65
- const weekdayTokens: {[token: string]: number} = {
66
- SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6
67
- };
68
-
69
66
  function fieldSegments(ir: IR, field: Field): Segment[] {
70
67
  return ir.analyses.segments[field] as Segment[];
71
68
  }
@@ -94,14 +91,9 @@ function joinList(items: string[]): string {
94
91
  return items.slice(0, -1).join(', ') + ' und ' + items[items.length - 1];
95
92
  }
96
93
 
97
- // The adverbial name for a weekday token (cron name or number; 7 = Sunday).
94
+ // The adverbial name for a canonical weekday number (0 = Sunday).
98
95
  function weekdayName(token: NameToken): string {
99
- if (token === '7' || token === 7) {
100
- return weekdayNames[0];
101
- }
102
-
103
- return weekdayNames[token as number] ||
104
- weekdayNames[weekdayTokens[token as string]];
96
+ return weekdayNames[+token];
105
97
  }
106
98
 
107
99
  // "montags bis freitags".
@@ -150,12 +142,10 @@ function everyNthHour(segment: StepSegment): string {
150
142
  return start === 0 ? base : base + ' ab ' + start + ' Uhr';
151
143
  }
152
144
 
145
+ // The Quartz weekday stem (`5L`, `MON#2`) is not number-canonicalized in the
146
+ // core, so it may still be a name token; resolve it via the core's index.
153
147
  function weekdayNoun(token: string): string {
154
- if (token === '7') {
155
- return weekdayNouns[0];
156
- }
157
-
158
- return weekdayNouns[token in weekdayTokens ? weekdayTokens[token] : +token];
148
+ return weekdayNouns[toFieldNumber(token, weekdayNumbers)];
159
149
  }
160
150
 
161
151
  // The Quartz weekday phrase: "am letzten Freitag des Monats", "am zweiten
@@ -193,18 +183,12 @@ function quartzDate(field: string): string | null {
193
183
  return null;
194
184
  }
195
185
 
196
- // Cron month tokens (part of cron syntax), mapped to indices. The month names
197
- // themselves are dialect-scoped and resolved from `opts.style.months`.
198
- const monthTokens: {[token: string]: number} = {
199
- JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
200
- JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12
201
- };
202
-
203
186
  type Months = GermanStyle['months'];
204
187
 
188
+ // The month names are dialect-scoped (resolved from `opts.style.months`);
189
+ // the canonical month number indexes them.
205
190
  function monthName(token: NameToken, months: Months): string {
206
- return (months[token as number] ||
207
- months[monthTokens[token as string]]) as string;
191
+ return months[+token] as string;
208
192
  }
209
193
 
210
194
  // "von Juni bis August".
@@ -462,7 +446,9 @@ function duringHours(ir: IR, times: HourTimesPlan, sep: string): string {
462
446
  return joinList(windows);
463
447
  }
464
448
 
465
- return 'in den Stunden von ' + joinList(times.fires.map(String)) + ' Uhr';
449
+ // A discrete set of hours is a list, not a range, so it takes no "von"
450
+ // (which would read as "von X bis Y"); it mirrors the minute list form.
451
+ return 'in den Stunden ' + joinList(times.fires.map(String)) + ' Uhr';
466
452
  }
467
453
 
468
454
  // --- Renderers. ---
@@ -509,12 +495,33 @@ function renderSecondsWithinMinute(
509
495
  ' jeder Stunde';
510
496
  }
511
497
 
512
- // A minute span inside one hour: "jede Minute von 9:00 bis 9:30 Uhr".
498
+ // The whole-hour noun in the genitive: "der Mitternachtsstunde" (0), "der
499
+ // Mittagsstunde" (12), or "der <H>-Uhr-Stunde" for any other hour.
500
+ function wholeHour(hour: number): string {
501
+ if (hour === 0) {
502
+ return 'der Mitternachtsstunde';
503
+ }
504
+
505
+ if (hour === 12) {
506
+ return 'der Mittagsstunde';
507
+ }
508
+
509
+ return 'der ' + hour + '-Uhr-Stunde';
510
+ }
511
+
512
+ // A minute span inside one hour: "jede Minute von 9:00 bis 9:30 Uhr". A
513
+ // wildcard minute is the whole hour, so it reads as that hour itself ("jede
514
+ // Minute der 9-Uhr-Stunde") rather than a synthesized "von 9:00 bis 9:59"
515
+ // range the source never stated; a plain range is a real window and keeps it.
513
516
  function renderMinuteSpanInHour(
514
517
  ir: IR,
515
518
  plan: Extract<PlanNode, {kind: 'minuteSpanInHour'}>,
516
519
  opts: Opts
517
520
  ): string {
521
+ if (ir.pattern.minute === '*') {
522
+ return 'jede Minute ' + wholeHour(plan.hour);
523
+ }
524
+
518
525
  const sep = opts.style.sep;
519
526
 
520
527
  return 'jede Minute von ' + spanTime(plan.hour, plan.span[0], sep) +
@@ -528,9 +535,47 @@ function renderComposeSeconds(
528
535
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
529
536
  opts: Opts
530
537
  ): string {
538
+ // A sub-minute second with the minute pinned to 0 and a specific hour: the
539
+ // clock-time rest would read "um 9 Uhr", which hides the pinned :00 (and so
540
+ // the one-minute confinement — 60 fires in :00, not 3,600 across the hour).
541
+ // Bind the seconds into the explicit clock minute in the genitive ("der
542
+ // Minute 9:00"); the recurring "täglich"/day frame is added in `describe`.
543
+ if (composeMinuteZero(ir, plan)) {
544
+ return secondsLead(ir) + ' ' +
545
+ clockMinuteGenitive(plan.rest.times, opts.style.sep);
546
+ }
547
+
531
548
  return secondsLead(ir) + ', ' + render(ir, plan.rest, opts);
532
549
  }
533
550
 
551
+ // True when a compose-seconds plan is a sub-minute second over a minute-0
552
+ // clock-time rest — the case that reads as the bare hour and so must surface
553
+ // the pinned clock minute.
554
+ function composeMinuteZero(
555
+ ir: IR,
556
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
557
+ ): plan is Extract<PlanNode, {kind: 'composeSeconds'}> &
558
+ {rest: Extract<PlanNode, {kind: 'clockTimes'}>} {
559
+ return plan.rest.kind === 'clockTimes' &&
560
+ plan.rest.times.every((time) => +time.minute === 0);
561
+ }
562
+
563
+ // The pinned clock minute in the genitive: "der Minute 9:00" for one hour,
564
+ // "der Minuten 9:00, 10:00 und 17:00" for several — the explicit ":00" so the
565
+ // minute-0 confinement stays visible.
566
+ function clockMinuteGenitive(
567
+ times: {hour: number; minute: number}[],
568
+ sep: string
569
+ ): string {
570
+ const clocks = times.map(function clock(time): string {
571
+ return time.hour + sep + pad(time.minute);
572
+ });
573
+
574
+ return clocks.length === 1 ?
575
+ 'der Minute ' + clocks[0] :
576
+ 'der Minuten ' + joinList(clocks);
577
+ }
578
+
534
579
  // A minute clause across discrete hours: "in den Minuten 0 bis 30, um 9 und
535
580
  // 17 Uhr".
536
581
  function renderMinutesAcrossHours(
@@ -739,15 +784,31 @@ function qualifier(ir: IR, months: Months): string {
739
784
  }
740
785
 
741
786
  // Plan kinds whose clause is a clock time: the qualifier leads them ("montags
742
- // um 9 Uhr"); a frequency clause trails it ("jede Minute montags").
787
+ // um 9 Uhr"); a frequency clause trails it ("jede Minute montags"). The
788
+ // minute-0 compose-seconds clause is anchored on a clock minute too, so the
789
+ // qualifier leads it ("montags jede Sekunde der Minute 9:00").
743
790
  const LEADING_PLANS = new Set(['clockTimes']);
744
791
 
792
+ // True when the leading qualifier should precede the clause: a clock-time
793
+ // plan, or the minute-0 compose-seconds clause that surfaces a clock minute.
794
+ function leadsQualifier(ir: IR): boolean {
795
+ return LEADING_PLANS.has(ir.plan.kind) || isComposeMinuteZero(ir);
796
+ }
797
+
798
+ // Whether the planned clause is the minute-0 compose-seconds confinement
799
+ // (a sub-minute second over a minute-0 clock-time rest).
800
+ function isComposeMinuteZero(ir: IR): boolean {
801
+ return ir.plan.kind === 'composeSeconds' &&
802
+ composeMinuteZero(ir, ir.plan);
803
+ }
804
+
745
805
  // True when the clause is a bare daily clock-time list and so needs the
746
- // "täglich" frame to read as recurring, not a one-off: clockTimes always, and
747
- // an uneven hour step (rendered as its fire list "um 0, 5, Uhr", not the
748
- // cadence "alle N Stunden"). A frequency clause already implies recurrence.
806
+ // "täglich" frame to read as recurring, not a one-off: clockTimes always, the
807
+ // minute-0 compose-seconds clause (a recurring clock minute), and an uneven
808
+ // hour step (rendered as its fire list "um 0, 5, Uhr", not the cadence "alle
809
+ // N Stunden"). A frequency clause already implies recurrence.
749
810
  function needsDailyFrame(ir: IR): boolean {
750
- if (ir.plan.kind === 'clockTimes') {
811
+ if (ir.plan.kind === 'clockTimes' || isComposeMinuteZero(ir)) {
751
812
  return true;
752
813
  }
753
814
 
@@ -802,7 +863,7 @@ function describe(ir: IR, opts: Opts): string {
802
863
  let base = core;
803
864
 
804
865
  if (qual) {
805
- base = LEADING_PLANS.has(ir.plan.kind) ?
866
+ base = leadsQualifier(ir) ?
806
867
  qual + ' ' + core :
807
868
  core + ' ' + qual;
808
869
  }
@@ -818,7 +879,10 @@ const de: Language<GermanStyle> = {
818
879
  fallback: 'ein unlesbares Cron-Muster',
819
880
  options: normalizeOptions,
820
881
  reboot: 'beim Systemstart',
821
- sentence: (description) => 'Läuft ' + description + '.'
882
+ // A description ending in a German ordinal already carries its period
883
+ // ("…am 8."), so closing the sentence must not double it.
884
+ sentence: (description) =>
885
+ 'Läuft ' + description + (description.endsWith('.') ? '' : '.')
822
886
  };
823
887
 
824
888
  export default de;