@forcecalendar/core 2.1.63 → 2.1.65

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,7 +4,6 @@
4
4
  */
5
5
 
6
6
  import { ICSParser } from './ICSParser.js';
7
- import { Event } from '../events/Event.js';
8
7
  import { RecurrenceEngineV2 } from '../events/RecurrenceEngineV2.js';
9
8
 
10
9
  export class ICSHandler {
@@ -180,40 +179,127 @@ export class ICSHandler {
180
179
  * @returns {Promise<Object>} Import results
181
180
  */
182
181
  async importFromURL(url, options = {}) {
183
- // Validate URL before fetching to prevent SSRF
184
- ICSHandler.validateURL(url);
182
+ const {
183
+ requestTimeout = 30000,
184
+ maxRedirects = 5,
185
+ maxFileSize = this.parser.maxFileSize || ICSParser.MAX_INPUT_SIZE
186
+ } = options;
185
187
 
186
188
  try {
187
189
  const controller = new AbortController();
188
- const timeout = setTimeout(() => controller.abort(), 30000); // 30s timeout
190
+ const timeout = setTimeout(() => controller.abort(), requestTimeout);
189
191
 
190
- const response = await fetch(url, { signal: controller.signal });
191
- clearTimeout(timeout);
192
+ try {
193
+ const response = await this.fetchSafeURL(url, {
194
+ signal: controller.signal,
195
+ maxRedirects
196
+ });
192
197
 
193
- if (!response.ok) {
194
- throw new Error(`Failed to fetch ICS: ${response.statusText}`);
195
- }
198
+ if (!response.ok) {
199
+ throw new Error(`Failed to fetch ICS: ${response.statusText}`);
200
+ }
196
201
 
197
- // Validate Content-Type header
198
- const contentType = response.headers.get('content-type') || '';
199
- const allowedTypes = ['text/calendar', 'text/plain', 'application/octet-stream'];
200
- const typeMatch = allowedTypes.some(t => contentType.toLowerCase().includes(t));
201
- if (contentType && !typeMatch) {
202
- throw new Error(
203
- `Unexpected Content-Type: ${contentType}. Expected text/calendar or text/plain`
204
- );
205
- }
202
+ // Validate Content-Type header
203
+ const contentType = response.headers.get('content-type') || '';
204
+ const allowedTypes = ['text/calendar', 'text/plain', 'application/octet-stream'];
205
+ const typeMatch = allowedTypes.some(t => contentType.toLowerCase().includes(t));
206
+ if (contentType && !typeMatch) {
207
+ throw new Error(
208
+ `Unexpected Content-Type: ${contentType}. Expected text/calendar or text/plain`
209
+ );
210
+ }
211
+
212
+ const contentLength = response.headers.get('content-length');
213
+ if (contentLength && Number(contentLength) > maxFileSize) {
214
+ throw new Error(`ICS response exceeds maximum size of ${maxFileSize / (1024 * 1024)}MB`);
215
+ }
206
216
 
207
- const icsString = await response.text();
208
- return this.import(icsString, options);
217
+ const icsString = await this.readResponseText(response, maxFileSize);
218
+ return this.import(icsString, options);
219
+ } finally {
220
+ clearTimeout(timeout);
221
+ }
209
222
  } catch (error) {
210
223
  if (error.name === 'AbortError') {
211
- throw new Error('Failed to import from URL: request timed out after 30 seconds');
224
+ throw new Error(`Failed to import from URL: request timed out after ${requestTimeout}ms`);
212
225
  }
213
226
  throw new Error(`Failed to import from URL: ${error.message}`);
214
227
  }
215
228
  }
216
229
 
230
+ /**
231
+ * Fetch a URL after validating the initial URL and each redirect target.
232
+ * @private
233
+ */
234
+ async fetchSafeURL(url, { signal, maxRedirects }) {
235
+ if (typeof fetch !== 'function') {
236
+ throw new Error('fetch API is not available in this environment');
237
+ }
238
+
239
+ let currentURL = url;
240
+ const manualRedirects = ICSHandler.isNodeRuntime();
241
+
242
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
243
+ await ICSHandler.validateURLForFetch(currentURL);
244
+
245
+ const response = await fetch(currentURL, {
246
+ signal,
247
+ redirect: manualRedirects ? 'manual' : 'follow'
248
+ });
249
+
250
+ if (
251
+ !manualRedirects ||
252
+ response.status < 300 ||
253
+ response.status >= 400 ||
254
+ !response.headers.get('location')
255
+ ) {
256
+ return response;
257
+ }
258
+
259
+ currentURL = new URL(response.headers.get('location'), currentURL).toString();
260
+ }
261
+
262
+ throw new Error(`Too many redirects while fetching ICS feed (limit ${maxRedirects})`);
263
+ }
264
+
265
+ /**
266
+ * Read a response body while enforcing a byte limit.
267
+ * @private
268
+ */
269
+ async readResponseText(response, maxFileSize) {
270
+ if (!response.body || typeof response.body.getReader !== 'function') {
271
+ const text = await response.text();
272
+ if (text.length > maxFileSize) {
273
+ throw new Error(`ICS response exceeds maximum size of ${maxFileSize / (1024 * 1024)}MB`);
274
+ }
275
+ return text;
276
+ }
277
+
278
+ const reader = response.body.getReader();
279
+ const decoder = new TextDecoder();
280
+ let received = 0;
281
+ let text = '';
282
+ let done = false;
283
+
284
+ while (!done) {
285
+ const chunk = await reader.read();
286
+ done = chunk.done;
287
+ if (done) break;
288
+
289
+ const { value } = chunk;
290
+ received += value.byteLength;
291
+ if (received > maxFileSize) {
292
+ await reader.cancel();
293
+ throw new Error(`ICS response exceeds maximum size of ${maxFileSize / (1024 * 1024)}MB`);
294
+ }
295
+
296
+ text += decoder.decode(value, { stream: true });
297
+ }
298
+
299
+ text += decoder.decode();
300
+ return text;
301
+ }
302
+
217
303
  /**
218
304
  * Validate a URL for safety (prevent SSRF attacks)
219
305
  * @param {string} url - URL to validate
@@ -234,32 +320,123 @@ export class ICSHandler {
234
320
  );
235
321
  }
236
322
 
237
- const hostname = parsed.hostname.toLowerCase();
323
+ if (parsed.username || parsed.password) {
324
+ throw new Error('URLs with embedded credentials are not allowed');
325
+ }
238
326
 
239
- // Block localhost
240
- if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
241
- throw new Error('URLs pointing to localhost are not allowed');
327
+ const hostname = ICSHandler.normalizeHostname(parsed.hostname);
328
+ if (ICSHandler.isBlockedHostname(hostname) || ICSHandler.isPrivateIPAddress(hostname)) {
329
+ throw new Error('URLs pointing to private/internal networks are not allowed');
242
330
  }
243
331
 
244
- // Block private/internal IP ranges
245
- const privatePatterns = [
246
- /^127\./, // 127.0.0.0/8
247
- /^10\./, // 10.0.0.0/8
248
- /^192\.168\./, // 192.168.0.0/16
249
- /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
250
- /^169\.254\./, // 169.254.0.0/16 (link-local)
251
- /^0\./, // 0.0.0.0/8
252
- /^\[?fe80:/i, // IPv6 link-local
253
- /^\[?fc00:/i, // IPv6 unique local
254
- /^\[?fd/i, // IPv6 unique local
255
- /^\[?::1\]?$/ // IPv6 loopback
256
- ];
257
-
258
- for (const pattern of privatePatterns) {
259
- if (pattern.test(hostname)) {
260
- throw new Error('URLs pointing to private/internal networks are not allowed');
332
+ return parsed;
333
+ }
334
+
335
+ /**
336
+ * Validate a URL and, in Node runtimes, ensure DNS does not resolve privately.
337
+ * @param {string} url - URL to validate
338
+ * @returns {Promise<URL>} Parsed safe URL
339
+ */
340
+ static async validateURLForFetch(url) {
341
+ const parsed = ICSHandler.validateURL(url);
342
+
343
+ if (ICSHandler.isNodeRuntime() && !ICSHandler.isIPAddress(parsed.hostname)) {
344
+ const dns = await import('node:dns/promises');
345
+ const addresses = await dns.lookup(parsed.hostname, { all: true, verbatim: true });
346
+
347
+ for (const address of addresses) {
348
+ if (ICSHandler.isPrivateIPAddress(address.address)) {
349
+ throw new Error('URL hostname resolves to a private/internal network address');
350
+ }
261
351
  }
262
352
  }
353
+
354
+ return parsed;
355
+ }
356
+
357
+ /**
358
+ * @private
359
+ */
360
+ static isNodeRuntime() {
361
+ return typeof process !== 'undefined' && !!process.versions?.node;
362
+ }
363
+
364
+ /**
365
+ * @private
366
+ */
367
+ static normalizeHostname(hostname) {
368
+ return String(hostname)
369
+ .trim()
370
+ .toLowerCase()
371
+ .replace(/^\[/, '')
372
+ .replace(/\]$/, '')
373
+ .replace(/\.$/, '');
374
+ }
375
+
376
+ /**
377
+ * @private
378
+ */
379
+ static isBlockedHostname(hostname) {
380
+ return (
381
+ hostname === 'localhost' ||
382
+ hostname.endsWith('.localhost') ||
383
+ hostname === 'metadata.google.internal'
384
+ );
385
+ }
386
+
387
+ /**
388
+ * @private
389
+ */
390
+ static isIPAddress(hostname) {
391
+ const normalized = ICSHandler.normalizeHostname(hostname);
392
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(normalized) || normalized.includes(':');
393
+ }
394
+
395
+ /**
396
+ * @private
397
+ */
398
+ static isPrivateIPAddress(address) {
399
+ const normalized = ICSHandler.normalizeHostname(address);
400
+ const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
401
+
402
+ if (ipv4Match) {
403
+ const octets = ipv4Match.slice(1).map(Number);
404
+ if (octets.some(octet => octet < 0 || octet > 255)) return true;
405
+
406
+ const [first, second] = octets;
407
+ return (
408
+ first === 0 ||
409
+ first === 10 ||
410
+ first === 127 ||
411
+ first >= 224 ||
412
+ (first === 100 && second >= 64 && second <= 127) ||
413
+ (first === 169 && second === 254) ||
414
+ (first === 172 && second >= 16 && second <= 31) ||
415
+ (first === 192 && second === 168) ||
416
+ (first === 198 && (second === 18 || second === 19))
417
+ );
418
+ }
419
+
420
+ const mappedIPv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
421
+ if (mappedIPv4) {
422
+ return ICSHandler.isPrivateIPAddress(mappedIPv4[1]);
423
+ }
424
+
425
+ if (!normalized.includes(':')) {
426
+ return false;
427
+ }
428
+
429
+ if (normalized === '::' || normalized === '::1') {
430
+ return true;
431
+ }
432
+
433
+ const firstHextet = parseInt(normalized.split(':')[0] || '0', 16);
434
+ return (
435
+ (firstHextet & 0xfe00) === 0xfc00 ||
436
+ (firstHextet & 0xffc0) === 0xfe80 ||
437
+ (firstHextet & 0xffc0) === 0xfec0 ||
438
+ (firstHextet & 0xff00) === 0xff00
439
+ );
263
440
  }
264
441
 
265
442
  /**
@@ -67,17 +67,15 @@ export class ICSParser {
67
67
 
68
68
  // Parse property and value
69
69
  const colonIndex = line.indexOf(':');
70
- const semicolonIndex = line.indexOf(';');
71
- const separatorIndex =
72
- semicolonIndex > -1 && semicolonIndex < colonIndex ? semicolonIndex : colonIndex;
73
70
 
74
- if (separatorIndex === -1) continue;
71
+ if (colonIndex === -1) continue;
75
72
 
76
- const property = line.substring(0, separatorIndex);
73
+ const property = line.substring(0, colonIndex);
74
+ const propertyName = this.parsePropertyParts(property).name;
77
75
  const value = line.substring(colonIndex + 1);
78
76
 
79
77
  // Handle component boundaries
80
- if (property === 'BEGIN') {
78
+ if (propertyName === 'BEGIN') {
81
79
  if (value === 'VEVENT') {
82
80
  // Enforce event count limit
83
81
  if (events.length >= ICSParser.MAX_EVENTS) {
@@ -88,7 +86,7 @@ export class ICSParser {
88
86
  } else if (value === 'VALARM') {
89
87
  inAlarm = true;
90
88
  }
91
- } else if (property === 'END') {
89
+ } else if (propertyName === 'END') {
92
90
  if (value === 'VEVENT' && currentEvent) {
93
91
  events.push(this.normalizeEvent(currentEvent));
94
92
  currentEvent = null;
@@ -154,10 +152,17 @@ export class ICSParser {
154
152
  lines.push(`DTEND;VALUE=DATE:${this.formatDate(event.end, true)}`);
155
153
  }
156
154
  } else {
157
- const tzid = event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
158
- lines.push(`DTSTART;TZID=${tzid}:${this.formatDate(event.start)}`);
155
+ const startTimeZone =
156
+ event.timeZone || event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
157
+ const endTimeZone = event.endTimeZone || startTimeZone;
158
+
159
+ lines.push(
160
+ `DTSTART;TZID=${this.escapeParamValue(startTimeZone)}:${this.formatDate(event.start)}`
161
+ );
159
162
  if (event.end) {
160
- lines.push(`DTEND;TZID=${tzid}:${this.formatDate(event.end)}`);
163
+ lines.push(
164
+ `DTEND;TZID=${this.escapeParamValue(endTimeZone)}:${this.formatDate(event.end)}`
165
+ );
161
166
  }
162
167
  }
163
168
 
@@ -247,8 +252,8 @@ export class ICSParser {
247
252
  * @private
248
253
  */
249
254
  parseProperty(property, value, event) {
250
- // Extract actual property name (before parameters)
251
- const propName = property.split(';')[0];
255
+ // Extract actual property name and parameters
256
+ const { name: propName, params } = this.parsePropertyParts(property);
252
257
 
253
258
  // Map to event property
254
259
  const eventProp = this.propertyMap[propName];
@@ -256,12 +261,29 @@ export class ICSParser {
256
261
 
257
262
  switch (propName) {
258
263
  case 'DTSTART':
259
- case 'DTEND':
260
- event[eventProp] = this.parseDate(value, property);
261
- if (property.includes('VALUE=DATE')) {
264
+ case 'DTEND': {
265
+ const parsed = this.parseDate(value, property);
266
+ event[eventProp] = parsed.date;
267
+
268
+ if (parsed.timeZone) {
269
+ if (propName === 'DTSTART') {
270
+ event.timeZone = parsed.timeZone;
271
+ } else {
272
+ event.endTimeZone = parsed.timeZone;
273
+ }
274
+ } else if (params.TZID) {
275
+ if (propName === 'DTSTART') {
276
+ event.timeZone = params.TZID;
277
+ } else {
278
+ event.endTimeZone = params.TZID;
279
+ }
280
+ }
281
+
282
+ if (params.VALUE === 'DATE') {
262
283
  event.allDay = true;
263
284
  }
264
285
  break;
286
+ }
265
287
 
266
288
  case 'SUMMARY':
267
289
  case 'DESCRIPTION':
@@ -313,7 +335,7 @@ export class ICSParser {
313
335
 
314
336
  case 'EXDATE': {
315
337
  if (!event.excludeDates) event.excludeDates = [];
316
- const dates = value.split(',').map(d => this.parseDate(d.trim(), property));
338
+ const dates = value.split(',').map(d => this.parseDate(d.trim(), property).date);
317
339
  event.excludeDates.push(...dates.filter(d => d !== null));
318
340
  break;
319
341
  }
@@ -325,16 +347,18 @@ export class ICSParser {
325
347
  * @private
326
348
  */
327
349
  parseDate(dateString, property = '') {
328
- // Remove timezone if present
329
- dateString = dateString.replace(/^TZID=[^:]+:/, '');
350
+ const { params } = this.parsePropertyParts(property);
330
351
 
331
352
  // Check if it's a date-only value
332
- if (property.includes('VALUE=DATE') || dateString.length === 8) {
353
+ if (params.VALUE === 'DATE' || dateString.length === 8) {
333
354
  // YYYYMMDD format
334
355
  const year = dateString.substring(0, 4);
335
356
  const month = dateString.substring(4, 6);
336
357
  const day = dateString.substring(6, 8);
337
- return new Date(year, month - 1, day);
358
+ return {
359
+ date: new Date(year, month - 1, day),
360
+ timeZone: null
361
+ };
338
362
  }
339
363
 
340
364
  // Full datetime: YYYYMMDDTHHMMSS[Z]
@@ -347,10 +371,47 @@ export class ICSParser {
347
371
 
348
372
  if (dateString.endsWith('Z')) {
349
373
  // UTC time
350
- return new Date(Date.UTC(year, month, day, hour, minute, second));
374
+ return {
375
+ date: new Date(Date.UTC(year, month, day, hour, minute, second)),
376
+ timeZone: 'UTC'
377
+ };
351
378
  }
352
- // Local time
353
- return new Date(year, month, day, hour, minute, second);
379
+
380
+ // Floating or TZID-bound wall time. The timezone itself is preserved on the
381
+ // event so Event can convert the wall-clock value to UTC consistently.
382
+ return {
383
+ date: new Date(year, month, day, hour, minute, second),
384
+ timeZone: params.TZID || null
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Parse property name and parameters from a line prefix.
390
+ * @private
391
+ */
392
+ parsePropertyParts(property = '') {
393
+ const parts = property.split(';');
394
+ const name = (parts.shift() || '').toUpperCase();
395
+ const params = {};
396
+
397
+ for (const part of parts) {
398
+ const equalsIndex = part.indexOf('=');
399
+ if (equalsIndex === -1) continue;
400
+
401
+ const key = part.substring(0, equalsIndex).toUpperCase();
402
+ const rawValue = part.substring(equalsIndex + 1);
403
+ params[key] = rawValue.replace(/^"|"$/g, '');
404
+ }
405
+
406
+ return { name, params };
407
+ }
408
+
409
+ /**
410
+ * Escape an ICS parameter value.
411
+ * @private
412
+ */
413
+ escapeParamValue(value) {
414
+ return String(value).replace(/"/g, '');
354
415
  }
355
416
 
356
417
  /**
package/core/index.js CHANGED
@@ -28,7 +28,7 @@ export { RRuleParser } from './events/RRuleParser.js';
28
28
  export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
29
29
 
30
30
  // Version — keep in sync with package.json
31
- export const VERSION = '2.1.63';
31
+ export const VERSION = '2.1.65';
32
32
 
33
33
  // Default export
34
34
  export { Calendar as default } from './calendar/Calendar.js';
@@ -9,6 +9,8 @@ export class SearchWorkerManager {
9
9
  this.workerSupported = typeof Worker !== 'undefined';
10
10
  this.worker = null;
11
11
  this.indexReady = false;
12
+ this.indexMode = 'none';
13
+ this.workerExpectedCount = 0;
12
14
  this.pendingSearches = [];
13
15
 
14
16
  // Fallback to main thread if workers not available
@@ -19,7 +21,8 @@ export class SearchWorkerManager {
19
21
  chunkSize: 100, // Events per indexing batch
20
22
  maxWorkers: 4, // Max parallel workers
21
23
  indexThreshold: 1000, // Use workers above this event count
22
- cacheSize: 50 // LRU cache for search results
24
+ cacheSize: 50, // LRU cache for search results
25
+ searchTimeout: 10000 // Worker search timeout in milliseconds
23
26
  };
24
27
 
25
28
  // Search result cache
@@ -35,7 +38,7 @@ export class SearchWorkerManager {
35
38
  initializeWorker() {
36
39
  if (!this.workerSupported) {
37
40
  // Use InvertedIndex as fallback
38
- this.fallbackIndex = new InvertedIndex();
41
+ this.ensureFallbackIndex();
39
42
  return;
40
43
  }
41
44
 
@@ -51,13 +54,14 @@ export class SearchWorkerManager {
51
54
  events[event.id] = event;
52
55
 
53
56
  // Index each field
54
- const fields = ['title', 'description', 'location', 'category'];
57
+ const fields = ['title', 'description', 'location', 'category', 'categories'];
55
58
  for (const field of fields) {
56
59
  const value = event[field];
57
60
  if (!value) continue;
58
61
 
59
62
  // Tokenize and index
60
- const tokens = tokenize(value.toLowerCase());
63
+ const values = Array.isArray(value) ? value : [value];
64
+ const tokens = values.flatMap(item => tokenize(String(item).toLowerCase()));
61
65
  for (const token of tokens) {
62
66
  if (!index[token]) {
63
67
  index[token] = new Set();
@@ -163,11 +167,19 @@ export class SearchWorkerManager {
163
167
  };
164
168
  `;
165
169
 
166
- // Create worker from blob
167
- const blob = new Blob([workerCode], { type: 'application/javascript' });
168
- const workerUrl = URL.createObjectURL(blob);
169
-
170
+ let workerUrl = null;
170
171
  try {
172
+ if (
173
+ typeof Blob === 'undefined' ||
174
+ typeof URL === 'undefined' ||
175
+ typeof URL.createObjectURL !== 'function'
176
+ ) {
177
+ throw new Error('Blob workers are not supported in this environment');
178
+ }
179
+
180
+ // Create worker from blob
181
+ const blob = new Blob([workerCode], { type: 'application/javascript' });
182
+ workerUrl = URL.createObjectURL(blob);
171
183
  this.worker = new Worker(workerUrl);
172
184
  this.setupWorkerHandlers();
173
185
 
@@ -180,10 +192,24 @@ export class SearchWorkerManager {
180
192
  // Clean up blob URL
181
193
  URL.revokeObjectURL(workerUrl);
182
194
  } catch (error) {
195
+ if (workerUrl) {
196
+ URL.revokeObjectURL(workerUrl);
197
+ }
183
198
  console.warn('Worker creation failed, falling back to main thread:', error);
184
199
  this.workerSupported = false;
200
+ this.ensureFallbackIndex();
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Ensure a fallback index exists.
206
+ * @private
207
+ */
208
+ ensureFallbackIndex() {
209
+ if (!this.fallbackIndex) {
185
210
  this.fallbackIndex = new InvertedIndex();
186
211
  }
212
+ return this.fallbackIndex;
187
213
  }
188
214
 
189
215
  /**
@@ -191,7 +217,7 @@ export class SearchWorkerManager {
191
217
  */
192
218
  setupWorkerHandlers() {
193
219
  this.worker.onmessage = e => {
194
- const { type, data } = e.data;
220
+ const { type } = e.data;
195
221
 
196
222
  switch (type) {
197
223
  case 'ready':
@@ -200,8 +226,10 @@ export class SearchWorkerManager {
200
226
  break;
201
227
 
202
228
  case 'indexed':
203
- // Process pending searches
204
- this.processPendingSearches();
229
+ if (e.data.count >= this.workerExpectedCount) {
230
+ this.indexMode = 'worker';
231
+ this.processPendingSearches();
232
+ }
205
233
  break;
206
234
 
207
235
  case 'results':
@@ -212,9 +240,12 @@ export class SearchWorkerManager {
212
240
 
213
241
  this.worker.onerror = error => {
214
242
  console.error('Worker error:', error);
243
+ this.rejectPendingSearches(new Error('Search worker failed'));
215
244
  // Fallback to main thread
216
245
  this.workerSupported = false;
217
- this.fallbackIndex = new InvertedIndex();
246
+ this.worker = null;
247
+ this.ensureFallbackIndex().buildIndex(this.eventStore.getAllEvents());
248
+ this.indexMode = 'fallback';
218
249
  };
219
250
  }
220
251
 
@@ -223,17 +254,26 @@ export class SearchWorkerManager {
223
254
  */
224
255
  async indexEvents() {
225
256
  const events = this.eventStore.getAllEvents();
257
+ this.searchCache.clear();
258
+ this.cacheOrder = [];
226
259
 
227
260
  // Use main thread for small datasets
228
261
  if (events.length < this.config.indexThreshold) {
229
- if (this.fallbackIndex) {
230
- this.fallbackIndex.buildIndex(events);
262
+ this.ensureFallbackIndex().buildIndex(events);
263
+ this.indexMode = 'fallback';
264
+
265
+ if (this.worker && this.indexReady) {
266
+ this.worker.postMessage({ type: 'clear' });
231
267
  }
232
268
  return;
233
269
  }
234
270
 
235
271
  // Chunk events for worker
236
272
  if (this.worker && this.indexReady) {
273
+ this.indexMode = 'worker-indexing';
274
+ this.workerExpectedCount = events.length;
275
+ this.worker.postMessage({ type: 'clear' });
276
+
237
277
  for (let i = 0; i < events.length; i += this.config.chunkSize) {
238
278
  const chunk = events.slice(i, i + this.config.chunkSize);
239
279
  this.worker.postMessage({
@@ -241,7 +281,11 @@ export class SearchWorkerManager {
241
281
  data: { events: chunk }
242
282
  });
243
283
  }
284
+ return;
244
285
  }
286
+
287
+ this.ensureFallbackIndex().buildIndex(events);
288
+ this.indexMode = 'fallback';
245
289
  }
246
290
 
247
291
  /**
@@ -257,9 +301,13 @@ export class SearchWorkerManager {
257
301
 
258
302
  // Use appropriate search method
259
303
  let results;
260
- if (this.worker && this.indexReady) {
261
- results = await this.workerSearch(query, options);
262
- } else if (this.fallbackIndex) {
304
+ if (this.worker && this.indexMode === 'worker') {
305
+ try {
306
+ results = await this.workerSearch(query, options);
307
+ } catch {
308
+ results = this.directSearch(query, options);
309
+ }
310
+ } else if (this.fallbackIndex && this.indexMode === 'fallback') {
263
311
  results = this.fallbackIndex.search(query, options);
264
312
  } else {
265
313
  // Direct search as last resort
@@ -276,14 +324,20 @@ export class SearchWorkerManager {
276
324
  * Search using worker
277
325
  */
278
326
  workerSearch(query, options) {
279
- return new Promise(resolve => {
327
+ return new Promise((resolve, reject) => {
280
328
  const searchId = Date.now() + Math.random();
329
+ const timeoutId = setTimeout(() => {
330
+ this.pendingSearches = this.pendingSearches.filter(search => search.id !== searchId);
331
+ reject(new Error('Search worker timed out'));
332
+ }, options.timeout || this.config.searchTimeout);
281
333
 
282
334
  this.pendingSearches.push({
283
335
  id: searchId,
284
336
  query,
285
337
  options,
286
- resolve
338
+ resolve,
339
+ reject,
340
+ timeoutId
287
341
  });
288
342
 
289
343
  this.worker.postMessage({
@@ -309,14 +363,23 @@ export class SearchWorkerManager {
309
363
  let score = 0;
310
364
 
311
365
  // Check each field
312
- const fields = options.fields || ['title', 'description', 'location'];
366
+ const fields = options.fields || [
367
+ 'title',
368
+ 'description',
369
+ 'location',
370
+ 'category',
371
+ 'categories'
372
+ ];
313
373
  for (const field of fields) {
314
374
  const value = event[field];
315
375
  if (!value) continue;
316
376
 
317
- const valueLower = value.toLowerCase();
318
- if (valueLower.includes(queryLower)) {
319
- score += field === 'title' ? 20 : 10;
377
+ const values = Array.isArray(value) ? value : [value];
378
+ for (const item of values) {
379
+ const valueLower = String(item).toLowerCase();
380
+ if (valueLower.includes(queryLower)) {
381
+ score += field === 'title' ? 20 : 10;
382
+ }
320
383
  }
321
384
  }
322
385
 
@@ -340,6 +403,7 @@ export class SearchWorkerManager {
340
403
  handleSearchResults(data) {
341
404
  const pending = this.pendingSearches.find(s => s.id === data.id);
342
405
  if (pending) {
406
+ clearTimeout(pending.timeoutId);
343
407
  pending.resolve(data.results);
344
408
  this.pendingSearches = this.pendingSearches.filter(s => s.id !== data.id);
345
409
  }
@@ -366,6 +430,10 @@ export class SearchWorkerManager {
366
430
  * Cache search results with LRU eviction
367
431
  */
368
432
  cacheResults(key, results) {
433
+ if (this.searchCache.has(key)) {
434
+ this.cacheOrder = this.cacheOrder.filter(existingKey => existingKey !== key);
435
+ }
436
+
369
437
  // Add to cache
370
438
  this.searchCache.set(key, results);
371
439
  this.cacheOrder.push(key);
@@ -383,6 +451,9 @@ export class SearchWorkerManager {
383
451
  clear() {
384
452
  this.searchCache.clear();
385
453
  this.cacheOrder = [];
454
+ this.indexMode = 'none';
455
+ this.workerExpectedCount = 0;
456
+ this.rejectPendingSearches(new Error('Search index was cleared'));
386
457
 
387
458
  if (this.worker) {
388
459
  this.worker.postMessage({ type: 'clear' });
@@ -402,6 +473,18 @@ export class SearchWorkerManager {
402
473
  }
403
474
  this.clear();
404
475
  }
476
+
477
+ /**
478
+ * Reject all pending worker searches.
479
+ * @private
480
+ */
481
+ rejectPendingSearches(error) {
482
+ for (const search of this.pendingSearches) {
483
+ clearTimeout(search.timeoutId);
484
+ search.reject(error);
485
+ }
486
+ this.pendingSearches = [];
487
+ }
405
488
  }
406
489
 
407
490
  /**
@@ -416,7 +499,8 @@ export class InvertedIndex {
416
499
  title: 2.0,
417
500
  description: 1.0,
418
501
  location: 1.5,
419
- category: 1.5
502
+ category: 1.5,
503
+ categories: 1.5
420
504
  };
421
505
  }
422
506
 
@@ -434,7 +518,8 @@ export class InvertedIndex {
434
518
  const value = event[field];
435
519
  if (!value) continue;
436
520
 
437
- const tokens = this.tokenize(value);
521
+ const values = Array.isArray(value) ? value : [value];
522
+ const tokens = values.flatMap(item => this.tokenize(String(item)));
438
523
  for (const token of tokens) {
439
524
  if (!this.index.has(token)) {
440
525
  this.index.set(token, new Map());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "2.1.63",
3
+ "version": "2.1.65",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",