@forcecalendar/core 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,420 @@
1
+ /**
2
+ * RRuleParser - Full RFC 5545 compliant RRULE parser
3
+ * Supports all RFC 5545 recurrence rule features
4
+ */
5
+
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);
112
+ }
113
+
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';
121
+ }
122
+
123
+ /**
124
+ * Parse BYDAY value
125
+ * @private
126
+ */
127
+ static parseByDay(value) {
128
+ const days = value.split(',');
129
+ const weekDays = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
130
+ const result = [];
131
+
132
+ for (const day of days) {
133
+ const match = day.match(/^([+-]?\d*)([A-Z]{2})$/);
134
+ if (match) {
135
+ const [_, nth, weekday] = match;
136
+ if (weekDays.includes(weekday)) {
137
+ result.push({
138
+ weekday,
139
+ nth: nth ? parseInt(nth, 10) : null
140
+ });
141
+ }
142
+ }
143
+ }
144
+
145
+ return result;
146
+ }
147
+
148
+ /**
149
+ * Parse comma-separated integer list
150
+ * @private
151
+ */
152
+ static parseIntList(value) {
153
+ return value.split(',').map(v => parseInt(v.trim(), 10)).filter(v => !isNaN(v));
154
+ }
155
+
156
+ /**
157
+ * Parse date/datetime value
158
+ * @private
159
+ */
160
+ static parseDateTime(value) {
161
+ // Handle different date formats
162
+ // YYYYMMDD
163
+ if (value.length === 8) {
164
+ const year = parseInt(value.substr(0, 4), 10);
165
+ const month = parseInt(value.substr(4, 2), 10) - 1;
166
+ const day = parseInt(value.substr(6, 2), 10);
167
+ return new Date(year, month, day);
168
+ }
169
+
170
+ // YYYYMMDDTHHMMSS
171
+ if (value.length === 15 && value[8] === 'T') {
172
+ const year = parseInt(value.substr(0, 4), 10);
173
+ const month = parseInt(value.substr(4, 2), 10) - 1;
174
+ const day = parseInt(value.substr(6, 2), 10);
175
+ const hour = parseInt(value.substr(9, 2), 10);
176
+ const minute = parseInt(value.substr(11, 2), 10);
177
+ const second = parseInt(value.substr(13, 2), 10);
178
+ return new Date(year, month, day, hour, minute, second);
179
+ }
180
+
181
+ // YYYYMMDDTHHMMSSZ (UTC)
182
+ if (value.length === 16 && value[8] === 'T' && value[15] === 'Z') {
183
+ const year = parseInt(value.substr(0, 4), 10);
184
+ const month = parseInt(value.substr(4, 2), 10) - 1;
185
+ const day = parseInt(value.substr(6, 2), 10);
186
+ const hour = parseInt(value.substr(9, 2), 10);
187
+ const minute = parseInt(value.substr(11, 2), 10);
188
+ const second = parseInt(value.substr(13, 2), 10);
189
+ return new Date(Date.UTC(year, month, day, hour, minute, second));
190
+ }
191
+
192
+ // Try standard date parse as fallback
193
+ return new Date(value);
194
+ }
195
+
196
+ /**
197
+ * Parse exception dates
198
+ * @private
199
+ */
200
+ static parseExceptionDates(value) {
201
+ const dates = value.split(',');
202
+ return dates.map(date => this.parseDateTime(date.trim()));
203
+ }
204
+
205
+ /**
206
+ * Validate and normalize rule
207
+ * @private
208
+ */
209
+ static validateRule(rule) {
210
+ // Ensure frequency is set
211
+ if (!rule.freq) {
212
+ rule.freq = 'DAILY';
213
+ }
214
+
215
+ // Cannot have both COUNT and UNTIL
216
+ if (rule.count && rule.until) {
217
+ throw new Error('RRULE cannot have both COUNT and UNTIL');
218
+ }
219
+
220
+ // Validate interval
221
+ if (rule.interval < 1) {
222
+ rule.interval = 1;
223
+ }
224
+
225
+ // Validate by* arrays
226
+ const validateArray = (arr, min, max) => {
227
+ return arr.filter(v => v >= min && v <= max);
228
+ };
229
+
230
+ rule.byMonth = validateArray(rule.byMonth || [], 1, 12);
231
+ rule.byMonthDay = validateArray(rule.byMonthDay || [], -31, 31).filter(v => v !== 0);
232
+ rule.byYearDay = validateArray(rule.byYearDay || [], -366, 366).filter(v => v !== 0);
233
+ rule.byWeekNo = validateArray(rule.byWeekNo || [], -53, 53).filter(v => v !== 0);
234
+ rule.byHour = validateArray(rule.byHour || [], 0, 23);
235
+ rule.byMinute = validateArray(rule.byMinute || [], 0, 59);
236
+ rule.bySecond = validateArray(rule.bySecond || [], 0, 59);
237
+
238
+ return rule;
239
+ }
240
+
241
+ /**
242
+ * Build RRULE string from rule object
243
+ * @param {Object} rule - Rule object
244
+ * @returns {string} RRULE string
245
+ */
246
+ static buildRRule(rule) {
247
+ const parts = [];
248
+
249
+ // Required frequency
250
+ parts.push(`FREQ=${rule.freq}`);
251
+
252
+ // Optional interval
253
+ if (rule.interval && rule.interval > 1) {
254
+ parts.push(`INTERVAL=${rule.interval}`);
255
+ }
256
+
257
+ // Count or until
258
+ if (rule.count) {
259
+ parts.push(`COUNT=${rule.count}`);
260
+ } else if (rule.until) {
261
+ parts.push(`UNTIL=${this.formatDateTime(rule.until)}`);
262
+ }
263
+
264
+ // By* rules
265
+ if (rule.byDay && rule.byDay.length > 0) {
266
+ const dayStr = rule.byDay.map(d => {
267
+ return d.nth ? `${d.nth}${d.weekday}` : d.weekday;
268
+ }).join(',');
269
+ parts.push(`BYDAY=${dayStr}`);
270
+ }
271
+
272
+ if (rule.byMonth && rule.byMonth.length > 0) {
273
+ parts.push(`BYMONTH=${rule.byMonth.join(',')}`);
274
+ }
275
+
276
+ if (rule.byMonthDay && rule.byMonthDay.length > 0) {
277
+ parts.push(`BYMONTHDAY=${rule.byMonthDay.join(',')}`);
278
+ }
279
+
280
+ if (rule.byYearDay && rule.byYearDay.length > 0) {
281
+ parts.push(`BYYEARDAY=${rule.byYearDay.join(',')}`);
282
+ }
283
+
284
+ if (rule.byWeekNo && rule.byWeekNo.length > 0) {
285
+ parts.push(`BYWEEKNO=${rule.byWeekNo.join(',')}`);
286
+ }
287
+
288
+ if (rule.bySetPos && rule.bySetPos.length > 0) {
289
+ parts.push(`BYSETPOS=${rule.bySetPos.join(',')}`);
290
+ }
291
+
292
+ if (rule.byHour && rule.byHour.length > 0) {
293
+ parts.push(`BYHOUR=${rule.byHour.join(',')}`);
294
+ }
295
+
296
+ if (rule.byMinute && rule.byMinute.length > 0) {
297
+ parts.push(`BYMINUTE=${rule.byMinute.join(',')}`);
298
+ }
299
+
300
+ if (rule.bySecond && rule.bySecond.length > 0) {
301
+ parts.push(`BYSECOND=${rule.bySecond.join(',')}`);
302
+ }
303
+
304
+ // Week start
305
+ if (rule.wkst && rule.wkst !== 'MO') {
306
+ parts.push(`WKST=${rule.wkst}`);
307
+ }
308
+
309
+ return parts.join(';');
310
+ }
311
+
312
+ /**
313
+ * Format date/datetime for RRULE
314
+ * @private
315
+ */
316
+ static formatDateTime(date) {
317
+ const year = date.getUTCFullYear();
318
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
319
+ const day = String(date.getUTCDate()).padStart(2, '0');
320
+ const hour = String(date.getUTCHours()).padStart(2, '0');
321
+ const minute = String(date.getUTCMinutes()).padStart(2, '0');
322
+ const second = String(date.getUTCSeconds()).padStart(2, '0');
323
+
324
+ return `${year}${month}${day}T${hour}${minute}${second}Z`;
325
+ }
326
+
327
+ /**
328
+ * Get human-readable description of rule
329
+ * @param {Object} rule - Parsed rule object
330
+ * @returns {string} Human-readable description
331
+ */
332
+ static getDescription(rule) {
333
+ const freqMap = {
334
+ 'SECONDLY': 'second',
335
+ 'MINUTELY': 'minute',
336
+ 'HOURLY': 'hour',
337
+ 'DAILY': 'day',
338
+ 'WEEKLY': 'week',
339
+ 'MONTHLY': 'month',
340
+ 'YEARLY': 'year'
341
+ };
342
+
343
+ const weekdayMap = {
344
+ 'SU': 'Sunday',
345
+ 'MO': 'Monday',
346
+ 'TU': 'Tuesday',
347
+ 'WE': 'Wednesday',
348
+ 'TH': 'Thursday',
349
+ 'FR': 'Friday',
350
+ 'SA': 'Saturday'
351
+ };
352
+
353
+ const nthMap = {
354
+ 1: 'first',
355
+ 2: 'second',
356
+ 3: 'third',
357
+ 4: 'fourth',
358
+ 5: 'fifth',
359
+ '-1': 'last',
360
+ '-2': 'second to last'
361
+ };
362
+
363
+ let description = 'Every';
364
+
365
+ // Interval
366
+ if (rule.interval > 1) {
367
+ description += ` ${rule.interval}`;
368
+ }
369
+
370
+ // Frequency
371
+ description += ` ${freqMap[rule.freq]}`;
372
+ if (rule.interval > 1) {
373
+ description += 's';
374
+ }
375
+
376
+ // By day
377
+ if (rule.byDay && rule.byDay.length > 0) {
378
+ if (rule.freq === 'WEEKLY') {
379
+ const days = rule.byDay.map(d => weekdayMap[d.weekday]).join(', ');
380
+ description += ` on ${days}`;
381
+ } else if (rule.freq === 'MONTHLY' || rule.freq === 'YEARLY') {
382
+ const dayDescs = rule.byDay.map(d => {
383
+ if (d.nth) {
384
+ return `the ${nthMap[d.nth] || d.nth} ${weekdayMap[d.weekday]}`;
385
+ }
386
+ return weekdayMap[d.weekday];
387
+ }).join(', ');
388
+ description += ` on ${dayDescs}`;
389
+ }
390
+ }
391
+
392
+ // By month day
393
+ if (rule.byMonthDay && rule.byMonthDay.length > 0) {
394
+ const days = rule.byMonthDay.map(d => {
395
+ if (d < 0) {
396
+ return `${Math.abs(d)} day(s) from the end`;
397
+ }
398
+ return `day ${d}`;
399
+ }).join(', ');
400
+ description += ` on ${days}`;
401
+ }
402
+
403
+ // By month
404
+ if (rule.byMonth && rule.byMonth.length > 0) {
405
+ const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
406
+ 'July', 'August', 'September', 'October', 'November', 'December'];
407
+ const months = rule.byMonth.map(m => monthNames[m - 1]).join(', ');
408
+ description += ` in ${months}`;
409
+ }
410
+
411
+ // Count or until
412
+ if (rule.count) {
413
+ description += `, ${rule.count} time${rule.count > 1 ? 's' : ''}`;
414
+ } else if (rule.until) {
415
+ description += `, until ${rule.until.toLocaleDateString()}`;
416
+ }
417
+
418
+ return description;
419
+ }
420
+ }