@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,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
+ }