@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,473 +4,465 @@
4
4
  */
5
5
 
6
6
  export class EventSearch {
7
- constructor(eventStore) {
8
- this.eventStore = eventStore;
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
+ }
9
36
 
10
- // Search index for performance
11
- this.searchIndex = new Map();
12
- this.indexFields = ['title', 'description', 'location', 'category'];
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
+ }
13
59
 
14
- // Build initial index
15
- this.rebuildIndex();
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
+ });
16
101
  }
17
102
 
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 [];
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);
35
110
  }
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
- }
111
+ if (event.categories && Array.isArray(event.categories)) {
112
+ return event.categories.some(cat => categorySet.has(cat));
58
113
  }
114
+ return false;
115
+ });
116
+ }
59
117
 
60
- // Sort results
61
- this.sortResults(results, sortBy);
62
-
63
- // Apply limit
64
- const limited = limit ? results.slice(0, limit) : results;
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
+ }
65
126
 
66
- // Return just the events (not the scoring metadata)
67
- return limited.map(r => r.event);
127
+ // Attendees filter
128
+ if (attendees && attendees.length > 0) {
129
+ const attendeeEmails = new Set(
130
+ attendees.map(a => (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
+ });
68
139
  }
69
140
 
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
- }
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
+ }
102
146
 
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
- }
147
+ // All-day filter
148
+ if (allDay !== null) {
149
+ events = events.filter(event => event.allDay === allDay);
150
+ }
117
151
 
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
- }
152
+ // Recurring filter
153
+ if (recurring !== null) {
154
+ events = events.filter(event => {
155
+ const hasRecurrence = !!event.recurrence;
156
+ return recurring ? hasRecurrence : !hasRecurrence;
157
+ });
158
+ }
126
159
 
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
- }
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
+ }
140
167
 
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
- }
168
+ // Custom filter function
169
+ if (custom && typeof custom === 'function') {
170
+ events = events.filter(custom);
171
+ }
146
172
 
147
- // All-day filter
148
- if (allDay !== null) {
149
- events = events.filter(event => event.allDay === allDay);
150
- }
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
+ }
151
196
 
152
- // Recurring filter
153
- if (recurring !== null) {
154
- events = events.filter(event => {
155
- const hasRecurrence = !!event.recurrence;
156
- return recurring ? hasRecurrence : !hasRecurrence;
157
- });
158
- }
197
+ // Apply final limit if specified
198
+ if (options.limit) {
199
+ events = events.slice(0, options.limit);
200
+ }
159
201
 
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
- }
202
+ return events;
203
+ }
167
204
 
168
- // Custom filter function
169
- if (custom && typeof custom === 'function') {
170
- events = events.filter(custom);
171
- }
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 { field = 'title', limit = 10, minLength = 2 } = options;
172
213
 
173
- return events;
214
+ if (!partial || partial.length < minLength) {
215
+ return [];
174
216
  }
175
217
 
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
- }
218
+ const normalizedPartial = partial.toLowerCase();
219
+ const suggestions = new Set();
220
+ const events = this.eventStore.getAllEvents();
196
221
 
197
- // Apply final limit if specified
198
- if (options.limit) {
199
- events = events.slice(0, options.limit);
200
- }
222
+ for (const event of events) {
223
+ const value = event[field];
224
+ if (!value) continue;
201
225
 
202
- return events;
226
+ const normalizedValue = value.toLowerCase();
227
+ if (normalizedValue.includes(normalizedPartial)) {
228
+ suggestions.add(value);
229
+ if (suggestions.size >= limit) break;
230
+ }
203
231
  }
204
232
 
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 [];
233
+ return Array.from(suggestions);
234
+ }
235
+
236
+ /**
237
+ * Get unique values for a field (for filter dropdowns)
238
+ * @param {string} field - Field name
239
+ * @returns {Array} Unique values
240
+ */
241
+ getUniqueValues(field) {
242
+ const values = new Set();
243
+ const events = this.eventStore.getAllEvents();
244
+
245
+ for (const event of events) {
246
+ // Special handling for category/categories
247
+ if (field === 'category') {
248
+ // Check both single category and categories array
249
+ if (event.category) {
250
+ values.add(event.category);
220
251
  }
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
- }
252
+ if (event.categories && Array.isArray(event.categories)) {
253
+ event.categories.forEach(cat => values.add(cat));
235
254
  }
236
-
237
- return Array.from(suggestions);
255
+ } else {
256
+ const value = event[field];
257
+ if (value !== undefined && value !== null && value !== '') {
258
+ if (Array.isArray(value)) {
259
+ value.forEach(v => values.add(v));
260
+ } else {
261
+ values.add(value);
262
+ }
263
+ }
264
+ }
238
265
  }
239
266
 
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
- }
267
+ return Array.from(values).sort();
268
+ }
269
+
270
+ /**
271
+ * Group events by a field
272
+ * @param {string} field - Field to group by
273
+ * @param {Object} options - Grouping options
274
+ * @returns {Object} Grouped events
275
+ */
276
+ groupBy(field, options = {}) {
277
+ const { sortGroups = true, sortEvents = false, includeEmpty = false } = options;
278
+
279
+ const groups = new Map();
280
+ const events = this.eventStore.getAllEvents();
281
+
282
+ for (const event of events) {
283
+ const value = event[field] || (includeEmpty ? `(No ${field})` : null);
284
+ if (value === null) continue;
285
+
286
+ if (!groups.has(value)) {
287
+ groups.set(value, []);
288
+ }
289
+ groups.get(value).push(event);
290
+ }
270
291
 
271
- return Array.from(values).sort();
292
+ // Sort events within groups if requested
293
+ if (sortEvents) {
294
+ for (const [key, eventList] of groups) {
295
+ eventList.sort((a, b) => a.start - b.start);
296
+ }
272
297
  }
273
298
 
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
+ // Convert to object and sort keys if requested
300
+ const result = {};
301
+ const keys = Array.from(groups.keys());
302
+ if (sortGroups) keys.sort();
299
303
 
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
- }
304
+ for (const key of keys) {
305
+ result[key] = groups.get(key);
306
+ }
306
307
 
307
- // Convert to object and sort keys if requested
308
- const result = {};
309
- const keys = Array.from(groups.keys());
310
- if (sortGroups) keys.sort();
308
+ return result;
309
+ }
311
310
 
312
- for (const key of keys) {
313
- result[key] = groups.get(key);
314
- }
311
+ /**
312
+ * Calculate match score for an event
313
+ * @private
314
+ */
315
+ calculateMatchScore(event, queryTerms, fields, options) {
316
+ let totalScore = 0;
317
+ const { fuzzy, caseSensitive } = options;
315
318
 
316
- return result;
317
- }
319
+ for (const field of fields) {
320
+ const value = event[field];
321
+ if (!value) continue;
322
+
323
+ const normalizedValue = caseSensitive ? value : value.toLowerCase();
318
324
 
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
- }
325
+ for (const term of queryTerms) {
326
+ // Exact match gets highest score
327
+ if (normalizedValue.includes(term)) {
328
+ totalScore += 10;
351
329
  }
330
+ // Fuzzy match if enabled
331
+ else if (fuzzy) {
332
+ const distance = this.levenshteinDistance(term, normalizedValue);
333
+ if (distance <= 2) {
334
+ totalScore += 5 - distance;
335
+ }
336
+ }
337
+ }
352
338
 
353
- return totalScore;
339
+ // Boost score for title matches
340
+ if (field === 'title') {
341
+ totalScore *= 2;
342
+ }
354
343
  }
355
344
 
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
- }
345
+ return totalScore;
346
+ }
347
+
348
+ /**
349
+ * Get match details for highlighting
350
+ * @private
351
+ */
352
+ getMatchDetails(event, queryTerms, fields, options) {
353
+ const matches = {};
354
+ const { caseSensitive } = options;
355
+
356
+ for (const field of fields) {
357
+ const value = event[field];
358
+ if (!value) continue;
359
+
360
+ const normalizedValue = caseSensitive ? value : value.toLowerCase();
361
+ const fieldMatches = [];
362
+
363
+ for (const term of queryTerms) {
364
+ let index = normalizedValue.indexOf(term);
365
+ while (index !== -1) {
366
+ fieldMatches.push({
367
+ start: index,
368
+ end: index + term.length,
369
+ term
370
+ });
371
+ index = normalizedValue.indexOf(term, index + 1);
386
372
  }
373
+ }
387
374
 
388
- return matches;
375
+ if (fieldMatches.length > 0) {
376
+ matches[field] = fieldMatches;
377
+ }
389
378
  }
390
379
 
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);
380
+ return matches;
381
+ }
382
+
383
+ /**
384
+ * Tokenize search query
385
+ * @private
386
+ */
387
+ tokenize(query) {
388
+ // Simple tokenization - split by spaces and remove empty strings
389
+ return query.split(/\s+/).filter(term => term.length > 0);
390
+ }
391
+
392
+ /**
393
+ * Calculate Levenshtein distance for fuzzy matching
394
+ * @private
395
+ */
396
+ levenshteinDistance(a, b) {
397
+ if (a.length === 0) return b.length;
398
+ if (b.length === 0) return a.length;
399
+
400
+ const matrix = [];
401
+ for (let i = 0; i <= b.length; i++) {
402
+ matrix[i] = [i];
398
403
  }
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];
404
+ for (let j = 0; j <= a.length; j++) {
405
+ matrix[0][j] = j;
431
406
  }
432
407
 
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;
408
+ for (let i = 1; i <= b.length; i++) {
409
+ for (let j = 1; j <= a.length; j++) {
410
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
411
+ matrix[i][j] = matrix[i - 1][j - 1];
412
+ } else {
413
+ matrix[i][j] = Math.min(
414
+ matrix[i - 1][j - 1] + 1,
415
+ matrix[i][j - 1] + 1,
416
+ matrix[i - 1][j] + 1
417
+ );
451
418
  }
419
+ }
452
420
  }
453
421
 
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
- }
422
+ return matrix[b.length][a.length];
423
+ }
424
+
425
+ /**
426
+ * Sort search results
427
+ * @private
428
+ */
429
+ sortResults(results, sortBy) {
430
+ switch (sortBy) {
431
+ case 'relevance':
432
+ results.sort((a, b) => b.score - a.score);
433
+ break;
434
+ case 'date':
435
+ results.sort((a, b) => a.event.start - b.event.start);
436
+ break;
437
+ case 'title':
438
+ results.sort((a, b) => a.event.title.localeCompare(b.event.title));
439
+ break;
440
+ default:
441
+ // Keep original order
442
+ break;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Rebuild search index
448
+ */
449
+ rebuildIndex() {
450
+ this.searchIndex.clear();
451
+ const events = this.eventStore.getAllEvents();
452
+
453
+ for (const event of events) {
454
+ for (const field of this.indexFields) {
455
+ const value = event[field];
456
+ if (!value) continue;
457
+
458
+ const tokens = this.tokenize(value.toLowerCase());
459
+ for (const token of tokens) {
460
+ if (!this.searchIndex.has(token)) {
461
+ this.searchIndex.set(token, new Set());
462
+ }
463
+ this.searchIndex.get(token).add(event.id);
474
464
  }
465
+ }
475
466
  }
476
- }
467
+ }
468
+ }