@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.
- package/core/calendar/Calendar.js +7 -9
- package/core/calendar/DateUtils.js +10 -9
- package/core/conflicts/ConflictDetector.js +24 -24
- package/core/events/Event.js +14 -20
- package/core/events/EventStore.js +70 -19
- package/core/events/RRuleParser.js +423 -394
- package/core/events/RecurrenceEngine.js +33 -21
- package/core/events/RecurrenceEngineV2.js +536 -562
- package/core/ics/ICSHandler.js +348 -348
- package/core/ics/ICSParser.js +433 -435
- package/core/index.js +1 -1
- package/core/integration/EnhancedCalendar.js +363 -398
- package/core/performance/AdaptiveMemoryManager.js +310 -308
- package/core/performance/LRUCache.js +3 -4
- package/core/performance/PerformanceOptimizer.js +4 -6
- package/core/search/EventSearch.js +409 -417
- package/core/search/SearchWorkerManager.js +338 -338
- package/core/state/StateManager.js +4 -2
- package/core/timezone/TimezoneDatabase.js +574 -271
- package/core/timezone/TimezoneManager.js +422 -402
- package/core/types.js +1 -1
- package/package.json +1 -1
|
@@ -4,473 +4,465 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export class EventSearch {
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
168
|
+
// Custom filter function
|
|
169
|
+
if (custom && typeof custom === 'function') {
|
|
170
|
+
events = events.filter(custom);
|
|
171
|
+
}
|
|
146
172
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
214
|
+
if (!partial || partial.length < minLength) {
|
|
215
|
+
return [];
|
|
174
216
|
}
|
|
175
217
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
222
|
+
for (const event of events) {
|
|
223
|
+
const value = event[field];
|
|
224
|
+
if (!value) continue;
|
|
201
225
|
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
const keys = Array.from(groups.keys());
|
|
310
|
-
if (sortGroups) keys.sort();
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
311
310
|
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
339
|
+
// Boost score for title matches
|
|
340
|
+
if (field === 'title') {
|
|
341
|
+
totalScore *= 2;
|
|
342
|
+
}
|
|
354
343
|
}
|
|
355
344
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
375
|
+
if (fieldMatches.length > 0) {
|
|
376
|
+
matches[field] = fieldMatches;
|
|
377
|
+
}
|
|
389
378
|
}
|
|
390
379
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
+
}
|