@forcecalendar/core 0.3.0 → 0.4.0
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,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearchWorkerManager - Offloads search indexing to Web Workers
|
|
3
|
+
* Provides scalable search for large event datasets
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class SearchWorkerManager {
|
|
7
|
+
constructor(eventStore) {
|
|
8
|
+
this.eventStore = eventStore;
|
|
9
|
+
this.workerSupported = typeof Worker !== 'undefined';
|
|
10
|
+
this.worker = null;
|
|
11
|
+
this.indexReady = false;
|
|
12
|
+
this.pendingSearches = [];
|
|
13
|
+
|
|
14
|
+
// Fallback to main thread if workers not available
|
|
15
|
+
this.fallbackIndex = null;
|
|
16
|
+
|
|
17
|
+
// Configuration
|
|
18
|
+
this.config = {
|
|
19
|
+
chunkSize: 100, // Events per indexing batch
|
|
20
|
+
maxWorkers: 4, // Max parallel workers
|
|
21
|
+
indexThreshold: 1000, // Use workers above this event count
|
|
22
|
+
cacheSize: 50 // LRU cache for search results
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Search result cache
|
|
26
|
+
this.searchCache = new Map();
|
|
27
|
+
this.cacheOrder = [];
|
|
28
|
+
|
|
29
|
+
this.initializeWorker();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Initialize the search worker
|
|
34
|
+
*/
|
|
35
|
+
initializeWorker() {
|
|
36
|
+
if (!this.workerSupported) {
|
|
37
|
+
// Use InvertedIndex as fallback
|
|
38
|
+
this.fallbackIndex = new InvertedIndex();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Create worker from inline code to avoid separate file requirement
|
|
43
|
+
const workerCode = `
|
|
44
|
+
let index = {};
|
|
45
|
+
let events = {};
|
|
46
|
+
let config = {};
|
|
47
|
+
|
|
48
|
+
// Build inverted index
|
|
49
|
+
function buildIndex(eventBatch) {
|
|
50
|
+
for (const event of eventBatch) {
|
|
51
|
+
events[event.id] = event;
|
|
52
|
+
|
|
53
|
+
// Index each field
|
|
54
|
+
const fields = ['title', 'description', 'location', 'category'];
|
|
55
|
+
for (const field of fields) {
|
|
56
|
+
const value = event[field];
|
|
57
|
+
if (!value) continue;
|
|
58
|
+
|
|
59
|
+
// Tokenize and index
|
|
60
|
+
const tokens = tokenize(value.toLowerCase());
|
|
61
|
+
for (const token of tokens) {
|
|
62
|
+
if (!index[token]) {
|
|
63
|
+
index[token] = new Set();
|
|
64
|
+
}
|
|
65
|
+
index[token].add(event.id);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Tokenize text
|
|
72
|
+
function tokenize(text) {
|
|
73
|
+
// Split on word boundaries and filter
|
|
74
|
+
return text.split(/\\W+/).filter(token =>
|
|
75
|
+
token.length > 1 && !stopWords.has(token)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Common stop words to ignore
|
|
80
|
+
const stopWords = new Set([
|
|
81
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on',
|
|
82
|
+
'at', 'to', 'for', 'of', 'with', 'by', 'from', 'is',
|
|
83
|
+
'are', 'was', 'were', 'been', 'be'
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// Search the index
|
|
87
|
+
function search(query, options) {
|
|
88
|
+
const queryTokens = tokenize(query.toLowerCase());
|
|
89
|
+
const results = new Map();
|
|
90
|
+
|
|
91
|
+
// Find matching events
|
|
92
|
+
for (const token of queryTokens) {
|
|
93
|
+
// Exact match
|
|
94
|
+
if (index[token]) {
|
|
95
|
+
for (const eventId of index[token]) {
|
|
96
|
+
if (!results.has(eventId)) {
|
|
97
|
+
results.set(eventId, 0);
|
|
98
|
+
}
|
|
99
|
+
results.set(eventId, results.get(eventId) + 10);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Prefix match for autocomplete
|
|
104
|
+
if (options.prefixMatch) {
|
|
105
|
+
for (const indexToken in index) {
|
|
106
|
+
if (indexToken.startsWith(token)) {
|
|
107
|
+
for (const eventId of index[indexToken]) {
|
|
108
|
+
if (!results.has(eventId)) {
|
|
109
|
+
results.set(eventId, 0);
|
|
110
|
+
}
|
|
111
|
+
results.set(eventId, results.get(eventId) + 5);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Sort by relevance and return
|
|
119
|
+
const sorted = Array.from(results.entries())
|
|
120
|
+
.sort((a, b) => b[1] - a[1])
|
|
121
|
+
.slice(0, options.limit || 100)
|
|
122
|
+
.map(([id, score]) => ({
|
|
123
|
+
event: events[id],
|
|
124
|
+
score
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
return sorted;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Message handler
|
|
131
|
+
self.onmessage = function(e) {
|
|
132
|
+
const { type, data } = e.data;
|
|
133
|
+
|
|
134
|
+
switch(type) {
|
|
135
|
+
case 'init':
|
|
136
|
+
config = data.config;
|
|
137
|
+
postMessage({ type: 'ready' });
|
|
138
|
+
break;
|
|
139
|
+
|
|
140
|
+
case 'index':
|
|
141
|
+
buildIndex(data.events);
|
|
142
|
+
postMessage({
|
|
143
|
+
type: 'indexed',
|
|
144
|
+
count: Object.keys(events).length
|
|
145
|
+
});
|
|
146
|
+
break;
|
|
147
|
+
|
|
148
|
+
case 'search':
|
|
149
|
+
const results = search(data.query, data.options);
|
|
150
|
+
postMessage({
|
|
151
|
+
type: 'results',
|
|
152
|
+
id: data.id,
|
|
153
|
+
results
|
|
154
|
+
});
|
|
155
|
+
break;
|
|
156
|
+
|
|
157
|
+
case 'clear':
|
|
158
|
+
index = {};
|
|
159
|
+
events = {};
|
|
160
|
+
postMessage({ type: 'cleared' });
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
// Create worker from blob
|
|
167
|
+
const blob = new Blob([workerCode], { type: 'application/javascript' });
|
|
168
|
+
const workerUrl = URL.createObjectURL(blob);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
this.worker = new Worker(workerUrl);
|
|
172
|
+
this.setupWorkerHandlers();
|
|
173
|
+
|
|
174
|
+
// Initialize worker
|
|
175
|
+
this.worker.postMessage({
|
|
176
|
+
type: 'init',
|
|
177
|
+
data: { config: this.config }
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Clean up blob URL
|
|
181
|
+
URL.revokeObjectURL(workerUrl);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.warn('Worker creation failed, falling back to main thread:', error);
|
|
184
|
+
this.workerSupported = false;
|
|
185
|
+
this.fallbackIndex = new InvertedIndex();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Setup worker message handlers
|
|
191
|
+
*/
|
|
192
|
+
setupWorkerHandlers() {
|
|
193
|
+
this.worker.onmessage = (e) => {
|
|
194
|
+
const { type, data } = e.data;
|
|
195
|
+
|
|
196
|
+
switch(type) {
|
|
197
|
+
case 'ready':
|
|
198
|
+
this.indexReady = true;
|
|
199
|
+
this.indexEvents();
|
|
200
|
+
break;
|
|
201
|
+
|
|
202
|
+
case 'indexed':
|
|
203
|
+
// Process pending searches
|
|
204
|
+
this.processPendingSearches();
|
|
205
|
+
break;
|
|
206
|
+
|
|
207
|
+
case 'results':
|
|
208
|
+
this.handleSearchResults(e.data);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
this.worker.onerror = (error) => {
|
|
214
|
+
console.error('Worker error:', error);
|
|
215
|
+
// Fallback to main thread
|
|
216
|
+
this.workerSupported = false;
|
|
217
|
+
this.fallbackIndex = new InvertedIndex();
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Index all events
|
|
223
|
+
*/
|
|
224
|
+
async indexEvents() {
|
|
225
|
+
const events = this.eventStore.getAllEvents();
|
|
226
|
+
|
|
227
|
+
// Use main thread for small datasets
|
|
228
|
+
if (events.length < this.config.indexThreshold) {
|
|
229
|
+
if (this.fallbackIndex) {
|
|
230
|
+
this.fallbackIndex.buildIndex(events);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Chunk events for worker
|
|
236
|
+
if (this.worker && this.indexReady) {
|
|
237
|
+
for (let i = 0; i < events.length; i += this.config.chunkSize) {
|
|
238
|
+
const chunk = events.slice(i, i + this.config.chunkSize);
|
|
239
|
+
this.worker.postMessage({
|
|
240
|
+
type: 'index',
|
|
241
|
+
data: { events: chunk }
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Search with caching and worker support
|
|
249
|
+
*/
|
|
250
|
+
async search(query, options = {}) {
|
|
251
|
+
const cacheKey = JSON.stringify({ query, options });
|
|
252
|
+
|
|
253
|
+
// Check cache
|
|
254
|
+
if (this.searchCache.has(cacheKey)) {
|
|
255
|
+
return this.searchCache.get(cacheKey);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Use appropriate search method
|
|
259
|
+
let results;
|
|
260
|
+
if (this.worker && this.indexReady) {
|
|
261
|
+
results = await this.workerSearch(query, options);
|
|
262
|
+
} else if (this.fallbackIndex) {
|
|
263
|
+
results = this.fallbackIndex.search(query, options);
|
|
264
|
+
} else {
|
|
265
|
+
// Direct search as last resort
|
|
266
|
+
results = this.directSearch(query, options);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Cache results
|
|
270
|
+
this.cacheResults(cacheKey, results);
|
|
271
|
+
|
|
272
|
+
return results;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Search using worker
|
|
277
|
+
*/
|
|
278
|
+
workerSearch(query, options) {
|
|
279
|
+
return new Promise((resolve) => {
|
|
280
|
+
const searchId = Date.now() + Math.random();
|
|
281
|
+
|
|
282
|
+
this.pendingSearches.push({
|
|
283
|
+
id: searchId,
|
|
284
|
+
resolve
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
this.worker.postMessage({
|
|
288
|
+
type: 'search',
|
|
289
|
+
data: {
|
|
290
|
+
id: searchId,
|
|
291
|
+
query,
|
|
292
|
+
options
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Direct search without worker
|
|
300
|
+
*/
|
|
301
|
+
directSearch(query, options) {
|
|
302
|
+
const events = this.eventStore.getAllEvents();
|
|
303
|
+
const queryLower = query.toLowerCase();
|
|
304
|
+
const results = [];
|
|
305
|
+
|
|
306
|
+
for (const event of events) {
|
|
307
|
+
let score = 0;
|
|
308
|
+
|
|
309
|
+
// Check each field
|
|
310
|
+
const fields = options.fields || ['title', 'description', 'location'];
|
|
311
|
+
for (const field of fields) {
|
|
312
|
+
const value = event[field];
|
|
313
|
+
if (!value) continue;
|
|
314
|
+
|
|
315
|
+
const valueLower = value.toLowerCase();
|
|
316
|
+
if (valueLower.includes(queryLower)) {
|
|
317
|
+
score += field === 'title' ? 20 : 10;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (score > 0) {
|
|
322
|
+
results.push({ event, score });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Sort and limit
|
|
327
|
+
results.sort((a, b) => b.score - a.score);
|
|
328
|
+
if (options.limit) {
|
|
329
|
+
return results.slice(0, options.limit);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return results;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Handle search results from worker
|
|
337
|
+
*/
|
|
338
|
+
handleSearchResults(data) {
|
|
339
|
+
const pending = this.pendingSearches.find(s => s.id === data.id);
|
|
340
|
+
if (pending) {
|
|
341
|
+
pending.resolve(data.results);
|
|
342
|
+
this.pendingSearches = this.pendingSearches.filter(s => s.id !== data.id);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Process any pending searches
|
|
348
|
+
*/
|
|
349
|
+
processPendingSearches() {
|
|
350
|
+
// Re-trigger pending searches after indexing
|
|
351
|
+
for (const search of this.pendingSearches) {
|
|
352
|
+
// Will be handled by worker
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Cache search results with LRU eviction
|
|
358
|
+
*/
|
|
359
|
+
cacheResults(key, results) {
|
|
360
|
+
// Add to cache
|
|
361
|
+
this.searchCache.set(key, results);
|
|
362
|
+
this.cacheOrder.push(key);
|
|
363
|
+
|
|
364
|
+
// Evict old entries
|
|
365
|
+
while (this.cacheOrder.length > this.config.cacheSize) {
|
|
366
|
+
const oldKey = this.cacheOrder.shift();
|
|
367
|
+
this.searchCache.delete(oldKey);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Clear index and cache
|
|
373
|
+
*/
|
|
374
|
+
clear() {
|
|
375
|
+
this.searchCache.clear();
|
|
376
|
+
this.cacheOrder = [];
|
|
377
|
+
|
|
378
|
+
if (this.worker) {
|
|
379
|
+
this.worker.postMessage({ type: 'clear' });
|
|
380
|
+
}
|
|
381
|
+
if (this.fallbackIndex) {
|
|
382
|
+
this.fallbackIndex.clear();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Destroy worker and clean up
|
|
388
|
+
*/
|
|
389
|
+
destroy() {
|
|
390
|
+
if (this.worker) {
|
|
391
|
+
this.worker.terminate();
|
|
392
|
+
this.worker = null;
|
|
393
|
+
}
|
|
394
|
+
this.clear();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* InvertedIndex - Efficient inverted index for text search
|
|
400
|
+
* Used as fallback when Web Workers not available
|
|
401
|
+
*/
|
|
402
|
+
export class InvertedIndex {
|
|
403
|
+
constructor() {
|
|
404
|
+
this.index = new Map(); // term -> Set of event IDs
|
|
405
|
+
this.events = new Map(); // event ID -> event
|
|
406
|
+
this.fieldBoosts = {
|
|
407
|
+
title: 2.0,
|
|
408
|
+
description: 1.0,
|
|
409
|
+
location: 1.5,
|
|
410
|
+
category: 1.5
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Build inverted index from events
|
|
416
|
+
*/
|
|
417
|
+
buildIndex(events) {
|
|
418
|
+
this.clear();
|
|
419
|
+
|
|
420
|
+
for (const event of events) {
|
|
421
|
+
this.events.set(event.id, event);
|
|
422
|
+
|
|
423
|
+
// Index each field with boost factors
|
|
424
|
+
for (const [field, boost] of Object.entries(this.fieldBoosts)) {
|
|
425
|
+
const value = event[field];
|
|
426
|
+
if (!value) continue;
|
|
427
|
+
|
|
428
|
+
const tokens = this.tokenize(value);
|
|
429
|
+
for (const token of tokens) {
|
|
430
|
+
if (!this.index.has(token)) {
|
|
431
|
+
this.index.set(token, new Map());
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const eventScores = this.index.get(token);
|
|
435
|
+
const currentScore = eventScores.get(event.id) || 0;
|
|
436
|
+
eventScores.set(event.id, currentScore + boost);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Tokenize text into searchable terms
|
|
444
|
+
*/
|
|
445
|
+
tokenize(text) {
|
|
446
|
+
return text
|
|
447
|
+
.toLowerCase()
|
|
448
|
+
.split(/\W+/)
|
|
449
|
+
.filter(token => token.length > 1);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Search the index
|
|
454
|
+
*/
|
|
455
|
+
search(query, options = {}) {
|
|
456
|
+
const queryTokens = this.tokenize(query);
|
|
457
|
+
const scores = new Map();
|
|
458
|
+
|
|
459
|
+
// Aggregate scores from all matching terms
|
|
460
|
+
for (const token of queryTokens) {
|
|
461
|
+
// Exact matches
|
|
462
|
+
if (this.index.has(token)) {
|
|
463
|
+
const eventScores = this.index.get(token);
|
|
464
|
+
for (const [eventId, tokenScore] of eventScores) {
|
|
465
|
+
const currentScore = scores.get(eventId) || 0;
|
|
466
|
+
scores.set(eventId, currentScore + tokenScore);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Prefix matches for autocomplete
|
|
471
|
+
if (options.prefixMatch) {
|
|
472
|
+
for (const [indexToken, eventScores] of this.index) {
|
|
473
|
+
if (indexToken.startsWith(token)) {
|
|
474
|
+
for (const [eventId, tokenScore] of eventScores) {
|
|
475
|
+
const currentScore = scores.get(eventId) || 0;
|
|
476
|
+
scores.set(eventId, currentScore + tokenScore * 0.5);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Convert to results array
|
|
484
|
+
const results = Array.from(scores.entries())
|
|
485
|
+
.map(([eventId, score]) => ({
|
|
486
|
+
event: this.events.get(eventId),
|
|
487
|
+
score
|
|
488
|
+
}))
|
|
489
|
+
.sort((a, b) => b.score - a.score);
|
|
490
|
+
|
|
491
|
+
// Apply limit
|
|
492
|
+
if (options.limit) {
|
|
493
|
+
return results.slice(0, options.limit);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return results;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Clear the index
|
|
501
|
+
*/
|
|
502
|
+
clear() {
|
|
503
|
+
this.index.clear();
|
|
504
|
+
this.events.clear();
|
|
505
|
+
}
|
|
506
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forcecalendar/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/
|
|
27
|
+
"url": "git+https://github.com/forceCalendar/core.git"
|
|
28
28
|
},
|
|
29
29
|
"keywords": [
|
|
30
30
|
"calendar",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"author": "",
|
|
41
41
|
"license": "MIT",
|
|
42
42
|
"bugs": {
|
|
43
|
-
"url": "https://github.com/
|
|
43
|
+
"url": "https://github.com/forceCalendar/core/issues"
|
|
44
44
|
},
|
|
45
|
-
"homepage": "https://github.com/
|
|
45
|
+
"homepage": "https://github.com/forceCalendar/core#readme",
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@babel/core": "^7.23.0",
|
|
48
48
|
"@babel/preset-env": "^7.23.0",
|