Files
openmct/src/api/objects/InMemorySearchProvider.js
2024-01-09 13:31:51 -08:00

587 lines
18 KiB
JavaScript

/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { v4 as uuid } from 'uuid';
class InMemorySearchProvider {
/**
* A search service which searches through domain objects in
* the filetree without using external search implementations.
*
* @constructor
* @param {Object} openmct
*/
constructor(openmct) {
/**
* Maximum number of concurrent index requests to allow.
*/
this.MAX_CONCURRENT_REQUESTS = 100;
/**
* If max results is not specified in query, use this as default.
*/
this.DEFAULT_MAX_RESULTS = 100;
this.openmct = openmct;
this.indexedIds = {};
this.indexedCompositions = {};
this.idsToIndex = [];
this.pendingIndex = {};
this.pendingRequests = 0;
this.worker = null;
/**
* If we don't have SharedWorkers available (e.g., iOS)
*/
this.localIndexedDomainObjects = {};
this.localIndexedAnnotationsByDomainObject = {};
this.localIndexedAnnotationsByTag = {};
this.pendingQueries = {};
this.onWorkerMessage = this.onWorkerMessage.bind(this);
this.onWorkerMessageError = this.onWorkerMessageError.bind(this);
this.localSearchForObjects = this.localSearchForObjects.bind(this);
this.localSearchForAnnotations = this.localSearchForAnnotations.bind(this);
this.localSearchForTags = this.localSearchForTags.bind(this);
this.onAnnotationCreation = this.onAnnotationCreation.bind(this);
this.onCompositionAdded = this.onCompositionAdded.bind(this);
this.onCompositionRemoved = this.onCompositionRemoved.bind(this);
this.onerror = this.onWorkerError.bind(this);
this.startIndexing = this.startIndexing.bind(this);
this.openmct.on('start', this.startIndexing);
this.openmct.on('destroy', () => {
if (this.worker && this.worker.port) {
this.worker.onerror = null;
this.worker.port.onmessage = null;
this.worker.port.onmessageerror = null;
this.worker.port.close();
}
Object.keys(this.indexedCompositions).forEach((keyString) => {
const composition = this.indexedCompositions[keyString];
composition.off('add', this.onCompositionAdded);
composition.off('remove', this.onCompositionRemoved);
});
this.destroyObservers(this.indexedIds);
this.destroyObservers(this.indexedCompositions);
});
}
startIndexing() {
const rootObject = this.openmct.objects.rootProvider.rootObject;
this.searchTypes = this.openmct.objects.SEARCH_TYPES;
this.supportedSearchTypes = [
this.searchTypes.OBJECTS,
this.searchTypes.ANNOTATIONS,
this.searchTypes.TAGS
];
this.scheduleForIndexing(rootObject.identifier);
this.indexAnnotations();
if (typeof SharedWorker !== 'undefined') {
this.worker = this.startSharedWorker();
} else {
// we must be on iOS
}
this.openmct.annotation.on('annotationCreated', this.onAnnotationCreation);
}
indexAnnotations() {
const theInMemorySearchProvider = this;
Object.values(this.openmct.objects.providers).forEach((objectProvider) => {
if (objectProvider.getAllObjects) {
const allObjects = objectProvider.getAllObjects();
if (allObjects) {
Object.values(allObjects).forEach((domainObject) => {
if (domainObject.type === 'annotation') {
theInMemorySearchProvider.scheduleForIndexing(domainObject.identifier);
}
});
}
}
});
}
/**
* @private
*/
getIntermediateResponse() {
let intermediateResponse = {};
intermediateResponse.promise = new Promise(function (resolve, reject) {
intermediateResponse.resolve = resolve;
intermediateResponse.reject = reject;
});
return intermediateResponse;
}
search(query, searchType) {
const queryId = uuid();
const pendingQuery = this.getIntermediateResponse();
this.pendingQueries[queryId] = pendingQuery;
const searchOptions = {
queryId,
searchType,
query,
maxResults: this.DEFAULT_MAX_RESULTS
};
if (this.worker) {
this.#dispatchSearchToWorker(searchOptions);
} else {
this.#localQueryFallBack(searchOptions);
}
return pendingQuery.promise;
}
/**
* @private
*/
#localQueryFallBack({ queryId, searchType, query, maxResults }) {
if (searchType === this.searchTypes.OBJECTS) {
return this.localSearchForObjects(queryId, query, maxResults);
} else if (searchType === this.searchTypes.ANNOTATIONS) {
return this.localSearchForAnnotations(queryId, query, maxResults);
} else if (searchType === this.searchTypes.TAGS) {
return this.localSearchForTags(queryId, query, maxResults);
} else {
throw new Error(`🤷‍♂️ Unknown search type passed: ${searchType}`);
}
}
supportsSearchType(searchType) {
return this.supportedSearchTypes.includes(searchType);
}
/**
* Handle messages from the worker.
* @private
*/
async onWorkerMessage(event) {
const pendingQuery = this.pendingQueries[event.data.queryId];
const modelResults = {
total: event.data.total
};
modelResults.hits = await Promise.all(
event.data.results.map(async (hit) => {
if (hit && hit.keyString) {
const identifier = this.openmct.objects.parseKeyString(hit.keyString);
const domainObject = await this.openmct.objects.get(identifier);
return domainObject;
}
})
);
pendingQuery.resolve(modelResults);
delete this.pendingQueries[event.data.queryId];
}
/**
* Handle error messages from the worker.
* @private
*/
onWorkerMessageError(event) {
console.error('⚙️ Error message from InMemorySearch worker ⚙️', event);
}
/**
* Handle errors from the worker.
* @private
*/
onWorkerError(event) {
console.error('⚙️ Error with InMemorySearch worker ⚙️', event);
}
/**
* @private
*/
startSharedWorker() {
// eslint-disable-next-line no-undef
const sharedWorkerURL = `${this.openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}inMemorySearchWorker.js`;
const sharedWorker = new SharedWorker(sharedWorkerURL, 'InMemorySearch Shared Worker');
sharedWorker.onerror = this.onWorkerError;
sharedWorker.port.onmessage = this.onWorkerMessage;
sharedWorker.port.onmessageerror = this.onWorkerMessageError;
sharedWorker.port.start();
return sharedWorker;
}
/**
* Schedule an id to be indexed at a later date. If there are less
* pending requests than the maximum allowed, this will kick off an indexing request.
* This is done only when indexing first begins and we need to index a lot of objects.
*
* @private
* @param {identifier} id to be indexed.
*/
scheduleForIndexing(identifier) {
const keyString = this.openmct.objects.makeKeyString(identifier);
const objectProvider = this.openmct.objects.getProvider(identifier);
if (objectProvider === undefined || objectProvider.search === undefined) {
if (!this.indexedIds[keyString] && !this.pendingIndex[keyString]) {
this.pendingIndex[keyString] = true;
this.idsToIndex.push(keyString);
}
}
this.keepIndexing();
}
/**
* If there are less pending requests than concurrent requests, keep
* firing requests.
*
* @private
*/
keepIndexing() {
while (this.pendingRequests < this.MAX_CONCURRENT_REQUESTS && this.idsToIndex.length) {
this.beginIndexRequest();
}
}
onAnnotationCreation(annotationObject) {
const objectProvider = this.openmct.objects.getProvider(annotationObject.identifier);
if (objectProvider === undefined || objectProvider.search === undefined) {
const provider = this;
provider.index(annotationObject);
}
}
onNameMutation(domainObject, name) {
const provider = this;
domainObject.name = name;
provider.index(domainObject);
}
onCompositionAdded(newDomainObjectToIndex) {
const provider = this;
// The object comes in as a mutable domain object, which has functions,
// which the index function cannot handle as it will eventually be serialized
// using structuredClone. Thus we're using JSON.parse/JSON.stringify to discard
// those functions.
const nonMutableDomainObject = JSON.parse(JSON.stringify(newDomainObjectToIndex));
const objectProvider = this.openmct.objects.getProvider(nonMutableDomainObject.identifier);
if (objectProvider === undefined || objectProvider.search === undefined) {
provider.index(nonMutableDomainObject);
}
}
onCompositionRemoved(domainObjectToRemoveIdentifier) {
const keyString = this.openmct.objects.makeKeyString(domainObjectToRemoveIdentifier);
if (this.indexedIds[keyString]) {
// we store the unobserve function in the indexedId map
this.indexedIds[keyString]();
delete this.indexedIds[keyString];
}
const composition = this.indexedCompositions[keyString];
if (composition) {
composition.off('add', this.onCompositionAdded);
composition.off('remove', this.onCompositionRemoved);
delete this.indexedCompositions[keyString];
}
}
/**
* Pass a domainObject to the worker to be indexed.
* If the object has composition, schedule those ids for later indexing.
* Watch for object changes and re-index object and children if so
*
* @private
* @param domainObject a domainObject
*/
async index(domainObject) {
const provider = this;
const keyString = this.openmct.objects.makeKeyString(domainObject.identifier);
const composition = this.openmct.composition.get(domainObject);
if (!this.indexedIds[keyString]) {
this.indexedIds[keyString] = this.openmct.objects.observe(
domainObject,
'name',
this.onNameMutation.bind(this, domainObject)
);
if (composition) {
composition.on('add', this.onCompositionAdded);
composition.on('remove', this.onCompositionRemoved);
this.indexedCompositions[keyString] = composition;
}
}
if (keyString !== 'ROOT') {
if (this.worker) {
this.worker.port.postMessage({
request: 'index',
model: domainObject,
keyString
});
} else {
this.localIndexItem(keyString, domainObject);
}
}
if (composition !== undefined) {
const children = await composition.load();
children.forEach((child) => provider.scheduleForIndexing(child.identifier));
}
}
/**
* Pulls an id from the indexing queue, loads it from the model service,
* and indexes it. Upon completion, tells the provider to keep
* indexing.
*
* @private
*/
async beginIndexRequest() {
const keyString = this.idsToIndex.shift();
const provider = this;
this.pendingRequests += 1;
const domainObject = await this.openmct.objects.get(keyString);
delete provider.pendingIndex[keyString];
try {
if (domainObject && domainObject.identifier) {
await provider.index(domainObject);
}
} catch (error) {
console.warn('Failed to index domain object ' + keyString, error);
}
setTimeout(function () {
provider.pendingRequests -= 1;
provider.keepIndexing();
}, 0);
}
/**
* Dispatch a search query to the worker and return a queryId.
*
* @private
* @returns {String} a unique query Id for the query.
*/
#dispatchSearchToWorker({ queryId, searchType, query, maxResults }) {
const message = {
request: searchType.toString(),
input: query,
maxResults,
queryId
};
this.worker.port.postMessage(message);
}
localIndexTags(keyString, objectToIndex, model) {
// add new tags
model.tags.forEach((tagID) => {
if (!this.localIndexedAnnotationsByTag[tagID]) {
this.localIndexedAnnotationsByTag[tagID] = [];
}
const existsInIndex = this.localIndexedAnnotationsByTag[tagID].some((indexedObject) => {
return indexedObject.keyString === objectToIndex.keyString;
});
if (!existsInIndex) {
this.localIndexedAnnotationsByTag[tagID].push(objectToIndex);
}
});
const tagsToRemoveFromIndex = Object.keys(this.localIndexedAnnotationsByTag).filter(
(indexedTag) => {
return !model.tags.includes(indexedTag);
}
);
tagsToRemoveFromIndex.forEach((tagToRemoveFromIndex) => {
this.localIndexedAnnotationsByTag[tagToRemoveFromIndex] = this.localIndexedAnnotationsByTag[
tagToRemoveFromIndex
].filter((indexedAnnotation) => {
const shouldKeep = indexedAnnotation.keyString !== keyString;
return shouldKeep;
});
});
}
localIndexAnnotation(objectToIndex, model) {
model.targets.forEach((target) => {
const targetID = target.keyString;
if (!this.localIndexedAnnotationsByDomainObject[targetID]) {
this.localIndexedAnnotationsByDomainObject[targetID] = [];
}
objectToIndex.targets = model.targets;
objectToIndex.tags = model.tags;
const existsInIndex = this.localIndexedAnnotationsByDomainObject[targetID].some(
(indexedObject) => {
return indexedObject.keyString === objectToIndex.keyString;
}
);
if (!existsInIndex) {
this.localIndexedAnnotationsByDomainObject[targetID].push(objectToIndex);
}
});
}
/**
* A local version of the same SharedWorker function
* if we don't have SharedWorkers available (e.g., iOS)
*/
localIndexItem(keyString, model) {
const objectToIndex = {
type: model.type,
name: model.name,
keyString
};
if (model && model.type === 'annotation') {
if (model.targets) {
this.localIndexAnnotation(objectToIndex, model);
}
if (model.tags) {
this.localIndexTags(keyString, objectToIndex, model);
}
} else {
this.localIndexedDomainObjects[keyString] = objectToIndex;
}
}
/**
* A local version of the same SharedWorker function
* if we don't have SharedWorkers available (e.g., iOS)
*
* Gets search results from the indexedItems based on provided search
* input. Returns matching results from indexedItems
*/
localSearchForObjects(queryId, searchInput, maxResults) {
// This results dictionary will have domain object ID keys which
// point to the value the domain object's score.
let results = [];
const input = searchInput.trim().toLowerCase();
const message = {
request: 'searchForObjects',
results: [],
total: 0,
queryId
};
results =
Object.values(this.localIndexedDomainObjects).filter((indexedItem) => {
return indexedItem.name.toLowerCase().includes(input);
}) || [];
message.total = results.length;
message.results = results.slice(0, maxResults);
const eventToReturn = {
data: message
};
this.onWorkerMessage(eventToReturn);
}
/**
* A local version of the same SharedWorker function
* if we don't have SharedWorkers available (e.g., iOS)
*/
localSearchForAnnotations(queryId, searchInput, maxResults) {
// This results dictionary will have domain object ID keys which
// point to the value the domain object's score.
let results = [];
const message = {
request: 'searchForAnnotations',
results: [],
total: 0,
queryId
};
results = this.localIndexedAnnotationsByDomainObject[searchInput] || [];
message.total = results.length;
message.results = results.slice(0, maxResults);
const eventToReturn = {
data: message
};
this.onWorkerMessage(eventToReturn);
}
/**
* A local version of the same SharedWorker function
* if we don't have SharedWorkers available (e.g., iOS)
*/
localSearchForTags(queryId, matchingTagKeys, maxResults) {
let results = [];
const message = {
request: 'searchForTags',
results: [],
total: 0,
queryId
};
if (matchingTagKeys) {
matchingTagKeys.forEach((matchingTag) => {
const matchingAnnotations = this.localIndexedAnnotationsByTag[matchingTag];
if (matchingAnnotations) {
matchingAnnotations.forEach((matchingAnnotation) => {
const existsInResults = results.some((indexedObject) => {
return matchingAnnotation.keyString === indexedObject.keyString;
});
if (!existsInResults) {
results.push(matchingAnnotation);
}
});
}
});
}
message.total = results.length;
message.results = results.slice(0, maxResults);
const eventToReturn = {
data: message
};
this.onWorkerMessage(eventToReturn);
}
destroyObservers(observers) {
Object.entries(observers).forEach(([keyString, unobserve]) => {
if (typeof unobserve === 'function') {
unobserve();
}
delete observers[keyString];
});
}
}
export default InMemorySearchProvider;