@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.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/ForceCalendar/force-calendar-core.git"
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/ForceCalendar/force-calendar-core/issues"
43
+ "url": "https://github.com/forceCalendar/core/issues"
44
44
  },
45
- "homepage": "https://github.com/ForceCalendar/force-calendar-core#readme",
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",