@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,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event Search Engine
|
|
3
|
+
* Full-text search and filtering for calendar events
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class EventSearch {
|
|
7
|
+
constructor(eventStore) {
|
|
8
|
+
this.eventStore = eventStore;
|
|
9
|
+
|
|
10
|
+
// Search index for performance
|
|
11
|
+
this.searchIndex = new Map();
|
|
12
|
+
this.indexFields = ['title', 'description', 'location', 'category'];
|
|
13
|
+
|
|
14
|
+
// Build initial index
|
|
15
|
+
this.rebuildIndex();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Search events by query string
|
|
20
|
+
* @param {string} query - Search query
|
|
21
|
+
* @param {Object} options - Search options
|
|
22
|
+
* @returns {Array} Matching events
|
|
23
|
+
*/
|
|
24
|
+
search(query, options = {}) {
|
|
25
|
+
const {
|
|
26
|
+
fields = this.indexFields, // Fields to search in
|
|
27
|
+
fuzzy = true, // Fuzzy matching
|
|
28
|
+
caseSensitive = false, // Case sensitive search
|
|
29
|
+
limit = null, // Max results
|
|
30
|
+
sortBy = 'relevance' // Sort results
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
if (!query || query.trim() === '') {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Normalize query
|
|
38
|
+
const normalizedQuery = caseSensitive ? query : query.toLowerCase();
|
|
39
|
+
const queryTerms = this.tokenize(normalizedQuery);
|
|
40
|
+
|
|
41
|
+
// Search through events
|
|
42
|
+
const results = [];
|
|
43
|
+
const events = this.eventStore.getAllEvents();
|
|
44
|
+
|
|
45
|
+
for (const event of events) {
|
|
46
|
+
const score = this.calculateMatchScore(event, queryTerms, fields, {
|
|
47
|
+
fuzzy,
|
|
48
|
+
caseSensitive
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (score > 0) {
|
|
52
|
+
results.push({
|
|
53
|
+
event,
|
|
54
|
+
score,
|
|
55
|
+
matches: this.getMatchDetails(event, queryTerms, fields, { caseSensitive })
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Sort results
|
|
61
|
+
this.sortResults(results, sortBy);
|
|
62
|
+
|
|
63
|
+
// Apply limit
|
|
64
|
+
const limited = limit ? results.slice(0, limit) : results;
|
|
65
|
+
|
|
66
|
+
// Return just the events (not the scoring metadata)
|
|
67
|
+
return limited.map(r => r.event);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Filter events by criteria
|
|
72
|
+
* @param {Object} filters - Filter criteria
|
|
73
|
+
* @returns {Array} Filtered events
|
|
74
|
+
*/
|
|
75
|
+
filter(filters) {
|
|
76
|
+
const {
|
|
77
|
+
dateRange = null,
|
|
78
|
+
categories = null,
|
|
79
|
+
locations = null,
|
|
80
|
+
attendees = null,
|
|
81
|
+
status = null,
|
|
82
|
+
allDay = null,
|
|
83
|
+
recurring = null,
|
|
84
|
+
hasReminders = null,
|
|
85
|
+
custom = null // Custom filter function
|
|
86
|
+
} = filters;
|
|
87
|
+
|
|
88
|
+
let events = this.eventStore.getAllEvents();
|
|
89
|
+
|
|
90
|
+
// Date range filter
|
|
91
|
+
if (dateRange) {
|
|
92
|
+
events = events.filter(event => {
|
|
93
|
+
const eventStart = event.start;
|
|
94
|
+
const eventEnd = event.end || event.start;
|
|
95
|
+
return (
|
|
96
|
+
(eventStart >= dateRange.start && eventStart <= dateRange.end) ||
|
|
97
|
+
(eventEnd >= dateRange.start && eventEnd <= dateRange.end) ||
|
|
98
|
+
(eventStart <= dateRange.start && eventEnd >= dateRange.end)
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Category filter
|
|
104
|
+
if (categories && categories.length > 0) {
|
|
105
|
+
const categorySet = new Set(categories);
|
|
106
|
+
events = events.filter(event => {
|
|
107
|
+
// Handle both single category and categories array
|
|
108
|
+
if (event.category) {
|
|
109
|
+
return categorySet.has(event.category);
|
|
110
|
+
}
|
|
111
|
+
if (event.categories && Array.isArray(event.categories)) {
|
|
112
|
+
return event.categories.some(cat => categorySet.has(cat));
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Location filter
|
|
119
|
+
if (locations && locations.length > 0) {
|
|
120
|
+
const locationSet = new Set(locations.map(l => l.toLowerCase()));
|
|
121
|
+
events = events.filter(event => {
|
|
122
|
+
if (!event.location) return false;
|
|
123
|
+
return locationSet.has(event.location.toLowerCase());
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Attendees filter
|
|
128
|
+
if (attendees && attendees.length > 0) {
|
|
129
|
+
const attendeeEmails = new Set(attendees.map(a =>
|
|
130
|
+
typeof a === 'string' ? a.toLowerCase() : a.email?.toLowerCase()
|
|
131
|
+
));
|
|
132
|
+
events = events.filter(event => {
|
|
133
|
+
if (!event.attendees || event.attendees.length === 0) return false;
|
|
134
|
+
return event.attendees.some(attendee => {
|
|
135
|
+
const email = attendee.email || attendee;
|
|
136
|
+
return attendeeEmails.has(email.toLowerCase());
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Status filter
|
|
142
|
+
if (status) {
|
|
143
|
+
const statusSet = new Set(Array.isArray(status) ? status : [status]);
|
|
144
|
+
events = events.filter(event => statusSet.has(event.status));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// All-day filter
|
|
148
|
+
if (allDay !== null) {
|
|
149
|
+
events = events.filter(event => event.allDay === allDay);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Recurring filter
|
|
153
|
+
if (recurring !== null) {
|
|
154
|
+
events = events.filter(event => {
|
|
155
|
+
const hasRecurrence = !!event.recurrence;
|
|
156
|
+
return recurring ? hasRecurrence : !hasRecurrence;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Reminders filter
|
|
161
|
+
if (hasReminders !== null) {
|
|
162
|
+
events = events.filter(event => {
|
|
163
|
+
const hasRem = event.reminders && event.reminders.length > 0;
|
|
164
|
+
return hasReminders ? hasRem : !hasRem;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Custom filter function
|
|
169
|
+
if (custom && typeof custom === 'function') {
|
|
170
|
+
events = events.filter(custom);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return events;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Advanced search combining text search and filters
|
|
178
|
+
* @param {string} query - Search query
|
|
179
|
+
* @param {Object} filters - Filter criteria
|
|
180
|
+
* @param {Object} options - Search options
|
|
181
|
+
* @returns {Array} Matching events
|
|
182
|
+
*/
|
|
183
|
+
advancedSearch(query, filters = {}, options = {}) {
|
|
184
|
+
// First apply filters
|
|
185
|
+
let events = this.filter(filters);
|
|
186
|
+
|
|
187
|
+
// Then search within filtered results if query provided
|
|
188
|
+
if (query && query.trim() !== '') {
|
|
189
|
+
const searchResults = this.search(query, {
|
|
190
|
+
...options,
|
|
191
|
+
limit: null // Don't limit during filtering phase
|
|
192
|
+
});
|
|
193
|
+
const searchIds = new Set(searchResults.map(e => e.id));
|
|
194
|
+
events = events.filter(e => searchIds.has(e.id));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Apply final limit if specified
|
|
198
|
+
if (options.limit) {
|
|
199
|
+
events = events.slice(0, options.limit);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return events;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get search suggestions/autocomplete
|
|
207
|
+
* @param {string} partial - Partial search term
|
|
208
|
+
* @param {Object} options - Suggestion options
|
|
209
|
+
* @returns {Array} Suggested terms
|
|
210
|
+
*/
|
|
211
|
+
getSuggestions(partial, options = {}) {
|
|
212
|
+
const {
|
|
213
|
+
field = 'title',
|
|
214
|
+
limit = 10,
|
|
215
|
+
minLength = 2
|
|
216
|
+
} = options;
|
|
217
|
+
|
|
218
|
+
if (!partial || partial.length < minLength) {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const normalizedPartial = partial.toLowerCase();
|
|
223
|
+
const suggestions = new Set();
|
|
224
|
+
const events = this.eventStore.getAllEvents();
|
|
225
|
+
|
|
226
|
+
for (const event of events) {
|
|
227
|
+
const value = event[field];
|
|
228
|
+
if (!value) continue;
|
|
229
|
+
|
|
230
|
+
const normalizedValue = value.toLowerCase();
|
|
231
|
+
if (normalizedValue.includes(normalizedPartial)) {
|
|
232
|
+
suggestions.add(value);
|
|
233
|
+
if (suggestions.size >= limit) break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return Array.from(suggestions);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get unique values for a field (for filter dropdowns)
|
|
242
|
+
* @param {string} field - Field name
|
|
243
|
+
* @returns {Array} Unique values
|
|
244
|
+
*/
|
|
245
|
+
getUniqueValues(field) {
|
|
246
|
+
const values = new Set();
|
|
247
|
+
const events = this.eventStore.getAllEvents();
|
|
248
|
+
|
|
249
|
+
for (const event of events) {
|
|
250
|
+
// Special handling for category/categories
|
|
251
|
+
if (field === 'category') {
|
|
252
|
+
// Check both single category and categories array
|
|
253
|
+
if (event.category) {
|
|
254
|
+
values.add(event.category);
|
|
255
|
+
}
|
|
256
|
+
if (event.categories && Array.isArray(event.categories)) {
|
|
257
|
+
event.categories.forEach(cat => values.add(cat));
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
const value = event[field];
|
|
261
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
262
|
+
if (Array.isArray(value)) {
|
|
263
|
+
value.forEach(v => values.add(v));
|
|
264
|
+
} else {
|
|
265
|
+
values.add(value);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return Array.from(values).sort();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Group events by a field
|
|
276
|
+
* @param {string} field - Field to group by
|
|
277
|
+
* @param {Object} options - Grouping options
|
|
278
|
+
* @returns {Object} Grouped events
|
|
279
|
+
*/
|
|
280
|
+
groupBy(field, options = {}) {
|
|
281
|
+
const {
|
|
282
|
+
sortGroups = true,
|
|
283
|
+
sortEvents = false,
|
|
284
|
+
includeEmpty = false
|
|
285
|
+
} = options;
|
|
286
|
+
|
|
287
|
+
const groups = new Map();
|
|
288
|
+
const events = this.eventStore.getAllEvents();
|
|
289
|
+
|
|
290
|
+
for (const event of events) {
|
|
291
|
+
const value = event[field] || (includeEmpty ? '(No ' + field + ')' : null);
|
|
292
|
+
if (value === null) continue;
|
|
293
|
+
|
|
294
|
+
if (!groups.has(value)) {
|
|
295
|
+
groups.set(value, []);
|
|
296
|
+
}
|
|
297
|
+
groups.get(value).push(event);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Sort events within groups if requested
|
|
301
|
+
if (sortEvents) {
|
|
302
|
+
for (const [key, eventList] of groups) {
|
|
303
|
+
eventList.sort((a, b) => a.start - b.start);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Convert to object and sort keys if requested
|
|
308
|
+
const result = {};
|
|
309
|
+
const keys = Array.from(groups.keys());
|
|
310
|
+
if (sortGroups) keys.sort();
|
|
311
|
+
|
|
312
|
+
for (const key of keys) {
|
|
313
|
+
result[key] = groups.get(key);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Calculate match score for an event
|
|
321
|
+
* @private
|
|
322
|
+
*/
|
|
323
|
+
calculateMatchScore(event, queryTerms, fields, options) {
|
|
324
|
+
let totalScore = 0;
|
|
325
|
+
const { fuzzy, caseSensitive } = options;
|
|
326
|
+
|
|
327
|
+
for (const field of fields) {
|
|
328
|
+
const value = event[field];
|
|
329
|
+
if (!value) continue;
|
|
330
|
+
|
|
331
|
+
const normalizedValue = caseSensitive ? value : value.toLowerCase();
|
|
332
|
+
|
|
333
|
+
for (const term of queryTerms) {
|
|
334
|
+
// Exact match gets highest score
|
|
335
|
+
if (normalizedValue.includes(term)) {
|
|
336
|
+
totalScore += 10;
|
|
337
|
+
}
|
|
338
|
+
// Fuzzy match if enabled
|
|
339
|
+
else if (fuzzy) {
|
|
340
|
+
const distance = this.levenshteinDistance(term, normalizedValue);
|
|
341
|
+
if (distance <= 2) {
|
|
342
|
+
totalScore += (5 - distance);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Boost score for title matches
|
|
348
|
+
if (field === 'title') {
|
|
349
|
+
totalScore *= 2;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return totalScore;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Get match details for highlighting
|
|
358
|
+
* @private
|
|
359
|
+
*/
|
|
360
|
+
getMatchDetails(event, queryTerms, fields, options) {
|
|
361
|
+
const matches = {};
|
|
362
|
+
const { caseSensitive } = options;
|
|
363
|
+
|
|
364
|
+
for (const field of fields) {
|
|
365
|
+
const value = event[field];
|
|
366
|
+
if (!value) continue;
|
|
367
|
+
|
|
368
|
+
const normalizedValue = caseSensitive ? value : value.toLowerCase();
|
|
369
|
+
const fieldMatches = [];
|
|
370
|
+
|
|
371
|
+
for (const term of queryTerms) {
|
|
372
|
+
let index = normalizedValue.indexOf(term);
|
|
373
|
+
while (index !== -1) {
|
|
374
|
+
fieldMatches.push({
|
|
375
|
+
start: index,
|
|
376
|
+
end: index + term.length,
|
|
377
|
+
term
|
|
378
|
+
});
|
|
379
|
+
index = normalizedValue.indexOf(term, index + 1);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (fieldMatches.length > 0) {
|
|
384
|
+
matches[field] = fieldMatches;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return matches;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Tokenize search query
|
|
393
|
+
* @private
|
|
394
|
+
*/
|
|
395
|
+
tokenize(query) {
|
|
396
|
+
// Simple tokenization - split by spaces and remove empty strings
|
|
397
|
+
return query.split(/\s+/).filter(term => term.length > 0);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Calculate Levenshtein distance for fuzzy matching
|
|
402
|
+
* @private
|
|
403
|
+
*/
|
|
404
|
+
levenshteinDistance(a, b) {
|
|
405
|
+
if (a.length === 0) return b.length;
|
|
406
|
+
if (b.length === 0) return a.length;
|
|
407
|
+
|
|
408
|
+
const matrix = [];
|
|
409
|
+
for (let i = 0; i <= b.length; i++) {
|
|
410
|
+
matrix[i] = [i];
|
|
411
|
+
}
|
|
412
|
+
for (let j = 0; j <= a.length; j++) {
|
|
413
|
+
matrix[0][j] = j;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (let i = 1; i <= b.length; i++) {
|
|
417
|
+
for (let j = 1; j <= a.length; j++) {
|
|
418
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
419
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
420
|
+
} else {
|
|
421
|
+
matrix[i][j] = Math.min(
|
|
422
|
+
matrix[i - 1][j - 1] + 1,
|
|
423
|
+
matrix[i][j - 1] + 1,
|
|
424
|
+
matrix[i - 1][j] + 1
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return matrix[b.length][a.length];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Sort search results
|
|
435
|
+
* @private
|
|
436
|
+
*/
|
|
437
|
+
sortResults(results, sortBy) {
|
|
438
|
+
switch (sortBy) {
|
|
439
|
+
case 'relevance':
|
|
440
|
+
results.sort((a, b) => b.score - a.score);
|
|
441
|
+
break;
|
|
442
|
+
case 'date':
|
|
443
|
+
results.sort((a, b) => a.event.start - b.event.start);
|
|
444
|
+
break;
|
|
445
|
+
case 'title':
|
|
446
|
+
results.sort((a, b) => a.event.title.localeCompare(b.event.title));
|
|
447
|
+
break;
|
|
448
|
+
default:
|
|
449
|
+
// Keep original order
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Rebuild search index
|
|
456
|
+
*/
|
|
457
|
+
rebuildIndex() {
|
|
458
|
+
this.searchIndex.clear();
|
|
459
|
+
const events = this.eventStore.getAllEvents();
|
|
460
|
+
|
|
461
|
+
for (const event of events) {
|
|
462
|
+
for (const field of this.indexFields) {
|
|
463
|
+
const value = event[field];
|
|
464
|
+
if (!value) continue;
|
|
465
|
+
|
|
466
|
+
const tokens = this.tokenize(value.toLowerCase());
|
|
467
|
+
for (const token of tokens) {
|
|
468
|
+
if (!this.searchIndex.has(token)) {
|
|
469
|
+
this.searchIndex.set(token, new Set());
|
|
470
|
+
}
|
|
471
|
+
this.searchIndex.get(token).add(event.id);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|