@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.
- package/core/calendar/Calendar.js +715 -0
- package/core/calendar/DateUtils.js +553 -0
- package/core/conflicts/ConflictDetector.js +517 -0
- package/core/events/Event.js +914 -0
- package/core/events/EventStore.js +1198 -0
- package/core/events/RRuleParser.js +420 -0
- package/core/events/RecurrenceEngine.js +382 -0
- package/core/ics/ICSHandler.js +389 -0
- package/core/ics/ICSParser.js +475 -0
- package/core/performance/AdaptiveMemoryManager.js +333 -0
- package/core/performance/LRUCache.js +118 -0
- package/core/performance/PerformanceOptimizer.js +523 -0
- package/core/search/EventSearch.js +476 -0
- package/core/state/StateManager.js +546 -0
- package/core/timezone/TimezoneDatabase.js +294 -0
- package/core/timezone/TimezoneManager.js +419 -0
- package/core/types.js +366 -0
- package/package.json +11 -9
|
@@ -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
|
+
}
|