@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.
- package/core/ics/ICSHandler.js +219 -42
- package/core/ics/ICSParser.js +84 -23
- package/core/index.js +1 -1
- package/core/search/SearchWorkerManager.js +110 -25
- package/package.json +1 -1
package/core/ics/ICSHandler.js
CHANGED
|
@@ -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
|
-
|
|
184
|
-
|
|
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(),
|
|
190
|
+
const timeout = setTimeout(() => controller.abort(), requestTimeout);
|
|
189
191
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
try {
|
|
193
|
+
const response = await this.fetchSafeURL(url, {
|
|
194
|
+
signal: controller.signal,
|
|
195
|
+
maxRedirects
|
|
196
|
+
});
|
|
192
197
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
throw new Error(`Failed to fetch ICS: ${response.statusText}`);
|
|
200
|
+
}
|
|
196
201
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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(
|
|
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
|
-
|
|
323
|
+
if (parsed.username || parsed.password) {
|
|
324
|
+
throw new Error('URLs with embedded credentials are not allowed');
|
|
325
|
+
}
|
|
238
326
|
|
|
239
|
-
|
|
240
|
-
if (hostname
|
|
241
|
-
throw new Error('URLs pointing to
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
/**
|
package/core/ics/ICSParser.js
CHANGED
|
@@ -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 (
|
|
71
|
+
if (colonIndex === -1) continue;
|
|
75
72
|
|
|
76
|
-
const property = line.substring(0,
|
|
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 (
|
|
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 (
|
|
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
|
|
158
|
-
|
|
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(
|
|
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
|
|
251
|
-
const propName =
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
374
|
+
return {
|
|
375
|
+
date: new Date(Date.UTC(year, month, day, hour, minute, second)),
|
|
376
|
+
timeZone: 'UTC'
|
|
377
|
+
};
|
|
351
378
|
}
|
|
352
|
-
|
|
353
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
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.
|
|
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
|
-
|
|
230
|
-
|
|
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.
|
|
261
|
-
|
|
262
|
-
|
|
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 || [
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
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());
|