@forcecalendar/core 2.1.0 → 2.1.2

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.
@@ -4,436 +4,465 @@
4
4
  */
5
5
 
6
6
  export class RRuleParser {
7
- /**
8
- * Parse an RRULE string into a structured rule object
9
- * @param {string|Object} rrule - RRULE string or rule object
10
- * @returns {Object} Parsed rule object
11
- */
12
- static parse(rrule) {
13
- // If already an object, validate and return
14
- if (typeof rrule === 'object') {
15
- return this.validateRule(rrule);
16
- }
17
-
18
- const rule = {
19
- freq: null,
20
- interval: 1,
21
- count: null,
22
- until: null,
23
- byDay: [],
24
- byWeekNo: [],
25
- byMonth: [],
26
- byMonthDay: [],
27
- byYearDay: [],
28
- bySetPos: [],
29
- byHour: [],
30
- byMinute: [],
31
- bySecond: [],
32
- wkst: 'MO', // Week start day
33
- exceptions: [],
34
- tzid: null
35
- };
36
-
37
- // Parse RRULE string
38
- const parts = rrule.toUpperCase().split(';');
39
-
40
- for (const part of parts) {
41
- const [key, value] = part.split('=');
42
-
43
- switch (key) {
44
- case 'FREQ':
45
- rule.freq = this.parseFrequency(value);
46
- break;
47
-
48
- case 'INTERVAL':
49
- rule.interval = parseInt(value, 10);
50
- if (rule.interval < 1) rule.interval = 1;
51
- break;
52
-
53
- case 'COUNT':
54
- rule.count = parseInt(value, 10);
55
- break;
56
-
57
- case 'UNTIL':
58
- rule.until = this.parseDateTime(value);
59
- break;
60
-
61
- case 'BYDAY':
62
- rule.byDay = this.parseByDay(value);
63
- break;
64
-
65
- case 'BYWEEKNO':
66
- rule.byWeekNo = this.parseIntList(value);
67
- break;
68
-
69
- case 'BYMONTH':
70
- rule.byMonth = this.parseIntList(value);
71
- break;
72
-
73
- case 'BYMONTHDAY':
74
- rule.byMonthDay = this.parseIntList(value);
75
- break;
76
-
77
- case 'BYYEARDAY':
78
- rule.byYearDay = this.parseIntList(value);
79
- break;
80
-
81
- case 'BYSETPOS':
82
- rule.bySetPos = this.parseIntList(value);
83
- break;
84
-
85
- case 'BYHOUR':
86
- rule.byHour = this.parseIntList(value);
87
- break;
88
-
89
- case 'BYMINUTE':
90
- rule.byMinute = this.parseIntList(value);
91
- break;
92
-
93
- case 'BYSECOND':
94
- rule.bySecond = this.parseIntList(value);
95
- break;
96
-
97
- case 'WKST':
98
- rule.wkst = value;
99
- break;
100
-
101
- case 'EXDATE':
102
- rule.exceptions = this.parseExceptionDates(value);
103
- break;
104
-
105
- case 'TZID':
106
- rule.tzid = value;
107
- break;
108
- }
109
- }
110
-
111
- return this.validateRule(rule);
7
+ /**
8
+ * Parse an RRULE string into a structured rule object
9
+ * @param {string|Object} rrule - RRULE string or rule object
10
+ * @returns {Object} Parsed rule object
11
+ */
12
+ static parse(rrule) {
13
+ // If already an object, validate and return
14
+ if (typeof rrule === 'object') {
15
+ return this.validateRule(rrule);
112
16
  }
113
17
 
114
- /**
115
- * Parse frequency value
116
- * @private
117
- */
118
- static parseFrequency(freq) {
119
- const validFrequencies = ['SECONDLY', 'MINUTELY', 'HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'];
120
- return validFrequencies.includes(freq) ? freq : 'DAILY';
18
+ const rule = {
19
+ freq: null,
20
+ interval: 1,
21
+ count: null,
22
+ until: null,
23
+ byDay: [],
24
+ byWeekNo: [],
25
+ byMonth: [],
26
+ byMonthDay: [],
27
+ byYearDay: [],
28
+ bySetPos: [],
29
+ byHour: [],
30
+ byMinute: [],
31
+ bySecond: [],
32
+ wkst: 'MO', // Week start day
33
+ exceptions: [],
34
+ tzid: null
35
+ };
36
+
37
+ // Parse RRULE string
38
+ const parts = rrule.toUpperCase().split(';');
39
+
40
+ for (const part of parts) {
41
+ const [key, value] = part.split('=');
42
+
43
+ switch (key) {
44
+ case 'FREQ':
45
+ rule.freq = this.parseFrequency(value);
46
+ break;
47
+
48
+ case 'INTERVAL':
49
+ rule.interval = parseInt(value, 10);
50
+ if (rule.interval < 1) rule.interval = 1;
51
+ break;
52
+
53
+ case 'COUNT':
54
+ rule.count = parseInt(value, 10);
55
+ break;
56
+
57
+ case 'UNTIL':
58
+ rule.until = this.parseDateTime(value);
59
+ break;
60
+
61
+ case 'BYDAY':
62
+ rule.byDay = this.parseByDay(value);
63
+ break;
64
+
65
+ case 'BYWEEKNO':
66
+ rule.byWeekNo = this.parseIntList(value);
67
+ break;
68
+
69
+ case 'BYMONTH':
70
+ rule.byMonth = this.parseIntList(value);
71
+ break;
72
+
73
+ case 'BYMONTHDAY':
74
+ rule.byMonthDay = this.parseIntList(value);
75
+ break;
76
+
77
+ case 'BYYEARDAY':
78
+ rule.byYearDay = this.parseIntList(value);
79
+ break;
80
+
81
+ case 'BYSETPOS':
82
+ rule.bySetPos = this.parseIntList(value);
83
+ break;
84
+
85
+ case 'BYHOUR':
86
+ rule.byHour = this.parseIntList(value);
87
+ break;
88
+
89
+ case 'BYMINUTE':
90
+ rule.byMinute = this.parseIntList(value);
91
+ break;
92
+
93
+ case 'BYSECOND':
94
+ rule.bySecond = this.parseIntList(value);
95
+ break;
96
+
97
+ case 'WKST':
98
+ rule.wkst = value;
99
+ break;
100
+
101
+ case 'EXDATE':
102
+ rule.exceptions = this.parseExceptionDates(value);
103
+ break;
104
+
105
+ case 'TZID':
106
+ rule.tzid = value;
107
+ break;
108
+ }
121
109
  }
122
110
 
123
- /**
124
- * Parse BYDAY value
125
- * Returns array of strings like ['MO', '2TU', '-1FR'] for compatibility with RecurrenceEngine
126
- * @private
127
- */
128
- static parseByDay(value) {
129
- const days = value.split(',');
130
- const weekDays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
131
- const result = [];
132
-
133
- for (const day of days) {
134
- const trimmed = day.trim().toUpperCase();
135
- const match = trimmed.match(/^([+-]?\d*)([A-Z]{2})$/);
136
- if (match) {
137
- const [_, nth, weekday] = match;
138
- if (weekDays.includes(weekday)) {
139
- // Return string format for RecurrenceEngine compatibility
140
- // e.g., 'MO', '2MO', '-1FR'
141
- result.push(nth ? `${nth}${weekday}` : weekday);
142
- }
143
- }
111
+ return this.validateRule(rule);
112
+ }
113
+
114
+ /**
115
+ * Parse frequency value
116
+ * @private
117
+ */
118
+ static parseFrequency(freq) {
119
+ const validFrequencies = [
120
+ 'SECONDLY',
121
+ 'MINUTELY',
122
+ 'HOURLY',
123
+ 'DAILY',
124
+ 'WEEKLY',
125
+ 'MONTHLY',
126
+ 'YEARLY'
127
+ ];
128
+ return validFrequencies.includes(freq) ? freq : 'DAILY';
129
+ }
130
+
131
+ /**
132
+ * Parse BYDAY value
133
+ * Returns array of strings like ['MO', '2TU', '-1FR'] for compatibility with RecurrenceEngine
134
+ * @private
135
+ */
136
+ static parseByDay(value) {
137
+ const days = value.split(',');
138
+ const weekDays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
139
+ const result = [];
140
+
141
+ for (const day of days) {
142
+ const trimmed = day.trim().toUpperCase();
143
+ const match = trimmed.match(/^([+-]?\d*)([A-Z]{2})$/);
144
+ if (match) {
145
+ const [_, nth, weekday] = match;
146
+ if (weekDays.includes(weekday)) {
147
+ // Return string format for RecurrenceEngine compatibility
148
+ // e.g., 'MO', '2MO', '-1FR'
149
+ result.push(nth ? `${nth}${weekday}` : weekday);
144
150
  }
145
-
146
- return result;
151
+ }
147
152
  }
148
153
 
149
- /**
150
- * Parse comma-separated integer list
151
- * @private
152
- */
153
- static parseIntList(value) {
154
- return value.split(',').map(v => parseInt(v.trim(), 10)).filter(v => !isNaN(v));
154
+ return result;
155
+ }
156
+
157
+ /**
158
+ * Parse comma-separated integer list
159
+ * @private
160
+ */
161
+ static parseIntList(value) {
162
+ return value
163
+ .split(',')
164
+ .map(v => parseInt(v.trim(), 10))
165
+ .filter(v => !isNaN(v));
166
+ }
167
+
168
+ /**
169
+ * Parse date/datetime value
170
+ * @private
171
+ */
172
+ static parseDateTime(value) {
173
+ // Handle different date formats
174
+ // YYYYMMDD
175
+ if (value.length === 8) {
176
+ const year = parseInt(value.substr(0, 4), 10);
177
+ const month = parseInt(value.substr(4, 2), 10) - 1;
178
+ const day = parseInt(value.substr(6, 2), 10);
179
+ return new Date(year, month, day);
155
180
  }
156
181
 
157
- /**
158
- * Parse date/datetime value
159
- * @private
160
- */
161
- static parseDateTime(value) {
162
- // Handle different date formats
163
- // YYYYMMDD
164
- if (value.length === 8) {
165
- const year = parseInt(value.substr(0, 4), 10);
166
- const month = parseInt(value.substr(4, 2), 10) - 1;
167
- const day = parseInt(value.substr(6, 2), 10);
168
- return new Date(year, month, day);
169
- }
170
-
171
- // YYYYMMDDTHHMMSS
172
- if (value.length === 15 && value[8] === 'T') {
173
- const year = parseInt(value.substr(0, 4), 10);
174
- const month = parseInt(value.substr(4, 2), 10) - 1;
175
- const day = parseInt(value.substr(6, 2), 10);
176
- const hour = parseInt(value.substr(9, 2), 10);
177
- const minute = parseInt(value.substr(11, 2), 10);
178
- const second = parseInt(value.substr(13, 2), 10);
179
- return new Date(year, month, day, hour, minute, second);
180
- }
181
-
182
- // YYYYMMDDTHHMMSSZ (UTC)
183
- if (value.length === 16 && value[8] === 'T' && value[15] === 'Z') {
184
- const year = parseInt(value.substr(0, 4), 10);
185
- const month = parseInt(value.substr(4, 2), 10) - 1;
186
- const day = parseInt(value.substr(6, 2), 10);
187
- const hour = parseInt(value.substr(9, 2), 10);
188
- const minute = parseInt(value.substr(11, 2), 10);
189
- const second = parseInt(value.substr(13, 2), 10);
190
- return new Date(Date.UTC(year, month, day, hour, minute, second));
191
- }
192
-
193
- // Try standard date parse as fallback
194
- return new Date(value);
182
+ // YYYYMMDDTHHMMSS
183
+ if (value.length === 15 && value[8] === 'T') {
184
+ const year = parseInt(value.substr(0, 4), 10);
185
+ const month = parseInt(value.substr(4, 2), 10) - 1;
186
+ const day = parseInt(value.substr(6, 2), 10);
187
+ const hour = parseInt(value.substr(9, 2), 10);
188
+ const minute = parseInt(value.substr(11, 2), 10);
189
+ const second = parseInt(value.substr(13, 2), 10);
190
+ return new Date(year, month, day, hour, minute, second);
195
191
  }
196
192
 
197
- /**
198
- * Parse exception dates
199
- * @private
200
- */
201
- static parseExceptionDates(value) {
202
- const dates = value.split(',');
203
- return dates.map(date => this.parseDateTime(date.trim()));
193
+ // YYYYMMDDTHHMMSSZ (UTC)
194
+ if (value.length === 16 && value[8] === 'T' && value[15] === 'Z') {
195
+ const year = parseInt(value.substr(0, 4), 10);
196
+ const month = parseInt(value.substr(4, 2), 10) - 1;
197
+ const day = parseInt(value.substr(6, 2), 10);
198
+ const hour = parseInt(value.substr(9, 2), 10);
199
+ const minute = parseInt(value.substr(11, 2), 10);
200
+ const second = parseInt(value.substr(13, 2), 10);
201
+ return new Date(Date.UTC(year, month, day, hour, minute, second));
204
202
  }
205
203
 
206
- /**
207
- * Validate and normalize rule
208
- * @private
209
- */
210
- static validateRule(rule) {
211
- // Ensure frequency is set
212
- if (!rule.freq) {
213
- rule.freq = 'DAILY';
214
- }
215
-
216
- // Cannot have both COUNT and UNTIL
217
- if (rule.count && rule.until) {
218
- throw new Error('RRULE cannot have both COUNT and UNTIL');
219
- }
220
-
221
- // Validate interval
222
- if (rule.interval < 1) {
223
- rule.interval = 1;
224
- }
225
-
226
- // Validate by* arrays
227
- const validateArray = (arr, min, max) => {
228
- return arr.filter(v => v >= min && v <= max);
229
- };
230
-
231
- rule.byMonth = validateArray(rule.byMonth || [], 1, 12);
232
- rule.byMonthDay = validateArray(rule.byMonthDay || [], -31, 31).filter(v => v !== 0);
233
- rule.byYearDay = validateArray(rule.byYearDay || [], -366, 366).filter(v => v !== 0);
234
- rule.byWeekNo = validateArray(rule.byWeekNo || [], -53, 53).filter(v => v !== 0);
235
- rule.byHour = validateArray(rule.byHour || [], 0, 23);
236
- rule.byMinute = validateArray(rule.byMinute || [], 0, 59);
237
- rule.bySecond = validateArray(rule.bySecond || [], 0, 59);
238
-
239
- return rule;
204
+ // Try standard date parse as fallback
205
+ return new Date(value);
206
+ }
207
+
208
+ /**
209
+ * Parse exception dates
210
+ * @private
211
+ */
212
+ static parseExceptionDates(value) {
213
+ const dates = value.split(',');
214
+ return dates.map(date => this.parseDateTime(date.trim()));
215
+ }
216
+
217
+ /**
218
+ * Validate and normalize rule
219
+ * @private
220
+ */
221
+ static validateRule(rule) {
222
+ // Ensure frequency is set
223
+ if (!rule.freq) {
224
+ rule.freq = 'DAILY';
240
225
  }
241
226
 
242
- /**
243
- * Build RRULE string from rule object
244
- * @param {Object} rule - Rule object
245
- * @returns {string} RRULE string
246
- */
247
- static buildRRule(rule) {
248
- const parts = [];
249
-
250
- // Required frequency
251
- parts.push(`FREQ=${rule.freq}`);
227
+ // Cannot have both COUNT and UNTIL
228
+ if (rule.count && rule.until) {
229
+ throw new Error('RRULE cannot have both COUNT and UNTIL');
230
+ }
252
231
 
253
- // Optional interval
254
- if (rule.interval && rule.interval > 1) {
255
- parts.push(`INTERVAL=${rule.interval}`);
256
- }
232
+ // Validate interval
233
+ if (rule.interval < 1) {
234
+ rule.interval = 1;
235
+ }
257
236
 
258
- // Count or until
259
- if (rule.count) {
260
- parts.push(`COUNT=${rule.count}`);
261
- } else if (rule.until) {
262
- parts.push(`UNTIL=${this.formatDateTime(rule.until)}`);
263
- }
237
+ // Validate by* arrays
238
+ const validateArray = (arr, min, max) => {
239
+ return arr.filter(v => v >= min && v <= max);
240
+ };
241
+
242
+ rule.byMonth = validateArray(rule.byMonth || [], 1, 12);
243
+ rule.byMonthDay = validateArray(rule.byMonthDay || [], -31, 31).filter(v => v !== 0);
244
+ rule.byYearDay = validateArray(rule.byYearDay || [], -366, 366).filter(v => v !== 0);
245
+ rule.byWeekNo = validateArray(rule.byWeekNo || [], -53, 53).filter(v => v !== 0);
246
+ rule.byHour = validateArray(rule.byHour || [], 0, 23);
247
+ rule.byMinute = validateArray(rule.byMinute || [], 0, 59);
248
+ rule.bySecond = validateArray(rule.bySecond || [], 0, 59);
249
+
250
+ return rule;
251
+ }
252
+
253
+ /**
254
+ * Build RRULE string from rule object
255
+ * @param {Object} rule - Rule object
256
+ * @returns {string} RRULE string
257
+ */
258
+ static buildRRule(rule) {
259
+ const parts = [];
260
+
261
+ // Required frequency
262
+ parts.push(`FREQ=${rule.freq}`);
263
+
264
+ // Optional interval
265
+ if (rule.interval && rule.interval > 1) {
266
+ parts.push(`INTERVAL=${rule.interval}`);
267
+ }
264
268
 
265
- // By* rules
266
- if (rule.byDay && rule.byDay.length > 0) {
267
- const dayStr = rule.byDay.map(d => {
268
- // Handle both string format ('MO', '2TU', '-1FR') from parseByDay
269
- // and object format ({nth: 2, weekday: 'MO'})
270
- if (typeof d === 'string') {
271
- return d;
272
- }
273
- return d.nth ? `${d.nth}${d.weekday}` : d.weekday;
274
- }).join(',');
275
- parts.push(`BYDAY=${dayStr}`);
276
- }
269
+ // Count or until
270
+ if (rule.count) {
271
+ parts.push(`COUNT=${rule.count}`);
272
+ } else if (rule.until) {
273
+ parts.push(`UNTIL=${this.formatDateTime(rule.until)}`);
274
+ }
277
275
 
278
- if (rule.byMonth && rule.byMonth.length > 0) {
279
- parts.push(`BYMONTH=${rule.byMonth.join(',')}`);
280
- }
276
+ // By* rules
277
+ if (rule.byDay && rule.byDay.length > 0) {
278
+ const dayStr = rule.byDay
279
+ .map(d => {
280
+ // Handle both string format ('MO', '2TU', '-1FR') from parseByDay
281
+ // and object format ({nth: 2, weekday: 'MO'})
282
+ if (typeof d === 'string') {
283
+ return d;
284
+ }
285
+ return d.nth ? `${d.nth}${d.weekday}` : d.weekday;
286
+ })
287
+ .join(',');
288
+ parts.push(`BYDAY=${dayStr}`);
289
+ }
281
290
 
282
- if (rule.byMonthDay && rule.byMonthDay.length > 0) {
283
- parts.push(`BYMONTHDAY=${rule.byMonthDay.join(',')}`);
284
- }
291
+ if (rule.byMonth && rule.byMonth.length > 0) {
292
+ parts.push(`BYMONTH=${rule.byMonth.join(',')}`);
293
+ }
285
294
 
286
- if (rule.byYearDay && rule.byYearDay.length > 0) {
287
- parts.push(`BYYEARDAY=${rule.byYearDay.join(',')}`);
288
- }
295
+ if (rule.byMonthDay && rule.byMonthDay.length > 0) {
296
+ parts.push(`BYMONTHDAY=${rule.byMonthDay.join(',')}`);
297
+ }
289
298
 
290
- if (rule.byWeekNo && rule.byWeekNo.length > 0) {
291
- parts.push(`BYWEEKNO=${rule.byWeekNo.join(',')}`);
292
- }
299
+ if (rule.byYearDay && rule.byYearDay.length > 0) {
300
+ parts.push(`BYYEARDAY=${rule.byYearDay.join(',')}`);
301
+ }
293
302
 
294
- if (rule.bySetPos && rule.bySetPos.length > 0) {
295
- parts.push(`BYSETPOS=${rule.bySetPos.join(',')}`);
296
- }
303
+ if (rule.byWeekNo && rule.byWeekNo.length > 0) {
304
+ parts.push(`BYWEEKNO=${rule.byWeekNo.join(',')}`);
305
+ }
297
306
 
298
- if (rule.byHour && rule.byHour.length > 0) {
299
- parts.push(`BYHOUR=${rule.byHour.join(',')}`);
300
- }
307
+ if (rule.bySetPos && rule.bySetPos.length > 0) {
308
+ parts.push(`BYSETPOS=${rule.bySetPos.join(',')}`);
309
+ }
301
310
 
302
- if (rule.byMinute && rule.byMinute.length > 0) {
303
- parts.push(`BYMINUTE=${rule.byMinute.join(',')}`);
304
- }
311
+ if (rule.byHour && rule.byHour.length > 0) {
312
+ parts.push(`BYHOUR=${rule.byHour.join(',')}`);
313
+ }
305
314
 
306
- if (rule.bySecond && rule.bySecond.length > 0) {
307
- parts.push(`BYSECOND=${rule.bySecond.join(',')}`);
308
- }
315
+ if (rule.byMinute && rule.byMinute.length > 0) {
316
+ parts.push(`BYMINUTE=${rule.byMinute.join(',')}`);
317
+ }
309
318
 
310
- // Week start
311
- if (rule.wkst && rule.wkst !== 'MO') {
312
- parts.push(`WKST=${rule.wkst}`);
313
- }
319
+ if (rule.bySecond && rule.bySecond.length > 0) {
320
+ parts.push(`BYSECOND=${rule.bySecond.join(',')}`);
321
+ }
314
322
 
315
- return parts.join(';');
323
+ // Week start
324
+ if (rule.wkst && rule.wkst !== 'MO') {
325
+ parts.push(`WKST=${rule.wkst}`);
316
326
  }
317
327
 
318
- /**
319
- * Format date/datetime for RRULE
320
- * @private
321
- */
322
- static formatDateTime(date) {
323
- const year = date.getUTCFullYear();
324
- const month = String(date.getUTCMonth() + 1).padStart(2, '0');
325
- const day = String(date.getUTCDate()).padStart(2, '0');
326
- const hour = String(date.getUTCHours()).padStart(2, '0');
327
- const minute = String(date.getUTCMinutes()).padStart(2, '0');
328
- const second = String(date.getUTCSeconds()).padStart(2, '0');
329
-
330
- return `${year}${month}${day}T${hour}${minute}${second}Z`;
328
+ return parts.join(';');
329
+ }
330
+
331
+ /**
332
+ * Format date/datetime for RRULE
333
+ * @private
334
+ */
335
+ static formatDateTime(date) {
336
+ const year = date.getUTCFullYear();
337
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
338
+ const day = String(date.getUTCDate()).padStart(2, '0');
339
+ const hour = String(date.getUTCHours()).padStart(2, '0');
340
+ const minute = String(date.getUTCMinutes()).padStart(2, '0');
341
+ const second = String(date.getUTCSeconds()).padStart(2, '0');
342
+
343
+ return `${year}${month}${day}T${hour}${minute}${second}Z`;
344
+ }
345
+
346
+ /**
347
+ * Get human-readable description of rule
348
+ * @param {Object} rule - Parsed rule object
349
+ * @returns {string} Human-readable description
350
+ */
351
+ static getDescription(rule) {
352
+ const freqMap = {
353
+ SECONDLY: 'second',
354
+ MINUTELY: 'minute',
355
+ HOURLY: 'hour',
356
+ DAILY: 'day',
357
+ WEEKLY: 'week',
358
+ MONTHLY: 'month',
359
+ YEARLY: 'year'
360
+ };
361
+
362
+ const weekdayMap = {
363
+ SU: 'Sunday',
364
+ MO: 'Monday',
365
+ TU: 'Tuesday',
366
+ WE: 'Wednesday',
367
+ TH: 'Thursday',
368
+ FR: 'Friday',
369
+ SA: 'Saturday'
370
+ };
371
+
372
+ const nthMap = {
373
+ 1: 'first',
374
+ 2: 'second',
375
+ 3: 'third',
376
+ 4: 'fourth',
377
+ 5: 'fifth',
378
+ '-1': 'last',
379
+ '-2': 'second to last'
380
+ };
381
+
382
+ let description = 'Every';
383
+
384
+ // Interval
385
+ if (rule.interval > 1) {
386
+ description += ` ${rule.interval}`;
331
387
  }
332
388
 
333
- /**
334
- * Get human-readable description of rule
335
- * @param {Object} rule - Parsed rule object
336
- * @returns {string} Human-readable description
337
- */
338
- static getDescription(rule) {
339
- const freqMap = {
340
- 'SECONDLY': 'second',
341
- 'MINUTELY': 'minute',
342
- 'HOURLY': 'hour',
343
- 'DAILY': 'day',
344
- 'WEEKLY': 'week',
345
- 'MONTHLY': 'month',
346
- 'YEARLY': 'year'
347
- };
348
-
349
- const weekdayMap = {
350
- 'SU': 'Sunday',
351
- 'MO': 'Monday',
352
- 'TU': 'Tuesday',
353
- 'WE': 'Wednesday',
354
- 'TH': 'Thursday',
355
- 'FR': 'Friday',
356
- 'SA': 'Saturday'
357
- };
358
-
359
- const nthMap = {
360
- 1: 'first',
361
- 2: 'second',
362
- 3: 'third',
363
- 4: 'fourth',
364
- 5: 'fifth',
365
- '-1': 'last',
366
- '-2': 'second to last'
367
- };
368
-
369
- let description = 'Every';
370
-
371
- // Interval
372
- if (rule.interval > 1) {
373
- description += ` ${rule.interval}`;
374
- }
389
+ // Frequency
390
+ description += ` ${freqMap[rule.freq]}`;
391
+ if (rule.interval > 1) {
392
+ description += 's';
393
+ }
375
394
 
376
- // Frequency
377
- description += ` ${freqMap[rule.freq]}`;
378
- if (rule.interval > 1) {
379
- description += 's';
395
+ // By day - handle both string format ('MO', '2TU') and object format ({nth, weekday})
396
+ if (rule.byDay && rule.byDay.length > 0) {
397
+ // Helper to extract weekday and nth from string or object
398
+ const parseDay = d => {
399
+ if (typeof d === 'string') {
400
+ const match = d.match(/^(-?\d+)?([A-Z]{2})$/);
401
+ if (match) {
402
+ return { nth: match[1] ? parseInt(match[1], 10) : null, weekday: match[2] };
403
+ }
404
+ return { nth: null, weekday: d };
380
405
  }
381
-
382
- // By day - handle both string format ('MO', '2TU') and object format ({nth, weekday})
383
- if (rule.byDay && rule.byDay.length > 0) {
384
- // Helper to extract weekday and nth from string or object
385
- const parseDay = (d) => {
386
- if (typeof d === 'string') {
387
- const match = d.match(/^(-?\d+)?([A-Z]{2})$/);
388
- if (match) {
389
- return { nth: match[1] ? parseInt(match[1], 10) : null, weekday: match[2] };
390
- }
391
- return { nth: null, weekday: d };
392
- }
393
- return d;
394
- };
395
-
396
- if (rule.freq === 'WEEKLY') {
397
- const days = rule.byDay.map(d => weekdayMap[parseDay(d).weekday]).join(', ');
398
- description += ` on ${days}`;
399
- } else if (rule.freq === 'MONTHLY' || rule.freq === 'YEARLY') {
400
- const dayDescs = rule.byDay.map(d => {
401
- const parsed = parseDay(d);
402
- if (parsed.nth) {
403
- return `the ${nthMap[parsed.nth] || parsed.nth} ${weekdayMap[parsed.weekday]}`;
404
- }
405
- return weekdayMap[parsed.weekday];
406
- }).join(', ');
407
- description += ` on ${dayDescs}`;
406
+ return d;
407
+ };
408
+
409
+ if (rule.freq === 'WEEKLY') {
410
+ const days = rule.byDay.map(d => weekdayMap[parseDay(d).weekday]).join(', ');
411
+ description += ` on ${days}`;
412
+ } else if (rule.freq === 'MONTHLY' || rule.freq === 'YEARLY') {
413
+ const dayDescs = rule.byDay
414
+ .map(d => {
415
+ const parsed = parseDay(d);
416
+ if (parsed.nth) {
417
+ return `the ${nthMap[parsed.nth] || parsed.nth} ${weekdayMap[parsed.weekday]}`;
408
418
  }
409
- }
410
-
411
- // By month day
412
- if (rule.byMonthDay && rule.byMonthDay.length > 0) {
413
- const days = rule.byMonthDay.map(d => {
414
- if (d < 0) {
415
- return `${Math.abs(d)} day(s) from the end`;
416
- }
417
- return `day ${d}`;
418
- }).join(', ');
419
- description += ` on ${days}`;
420
- }
419
+ return weekdayMap[parsed.weekday];
420
+ })
421
+ .join(', ');
422
+ description += ` on ${dayDescs}`;
423
+ }
424
+ }
421
425
 
422
- // By month
423
- if (rule.byMonth && rule.byMonth.length > 0) {
424
- const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
425
- 'July', 'August', 'September', 'October', 'November', 'December'];
426
- const months = rule.byMonth.map(m => monthNames[m - 1]).join(', ');
427
- description += ` in ${months}`;
428
- }
426
+ // By month day
427
+ if (rule.byMonthDay && rule.byMonthDay.length > 0) {
428
+ const days = rule.byMonthDay
429
+ .map(d => {
430
+ if (d < 0) {
431
+ return `${Math.abs(d)} day(s) from the end`;
432
+ }
433
+ return `day ${d}`;
434
+ })
435
+ .join(', ');
436
+ description += ` on ${days}`;
437
+ }
429
438
 
430
- // Count or until
431
- if (rule.count) {
432
- description += `, ${rule.count} time${rule.count > 1 ? 's' : ''}`;
433
- } else if (rule.until) {
434
- description += `, until ${rule.until.toLocaleDateString()}`;
435
- }
439
+ // By month
440
+ if (rule.byMonth && rule.byMonth.length > 0) {
441
+ const monthNames = [
442
+ 'January',
443
+ 'February',
444
+ 'March',
445
+ 'April',
446
+ 'May',
447
+ 'June',
448
+ 'July',
449
+ 'August',
450
+ 'September',
451
+ 'October',
452
+ 'November',
453
+ 'December'
454
+ ];
455
+ const months = rule.byMonth.map(m => monthNames[m - 1]).join(', ');
456
+ description += ` in ${months}`;
457
+ }
436
458
 
437
- return description;
459
+ // Count or until
460
+ if (rule.count) {
461
+ description += `, ${rule.count} time${rule.count > 1 ? 's' : ''}`;
462
+ } else if (rule.until) {
463
+ description += `, until ${rule.until.toLocaleDateString()}`;
438
464
  }
439
- }
465
+
466
+ return description;
467
+ }
468
+ }